본문 바로가기

Dev

[동시성 처리] MySQL named lock 으로 API 중복 호출 방지하기

최근 특정 API에 대해서 중복 호출, 전문 용어로 따닥이라고 하는 현상이 발생했습니다. 해당 API의 로직은 자동 결제 정보를 DB에 저장하는 역할을 하는데요, 동시에 두 번 요청이 들어오면서 동일한 데이터가 중복으로 쌓이는 문제가 있었습니다. 이로 인해 결제 프로세스에서 오류가 발생했고, 이를 해결하기 위한 적절한 방법을 고민하게 되었습니다. 이번 글에서는 이 문제를 해결하기 위해 MySQL Named Lock을 활용한 방법과 관련한 고민, 구현 과정 및 결과 등을 간단히 얘기해보겠습니다.

 

해결 방법 검토

1. 중복 방지 토큰(멱등성 키) 활용

클라이언트에서 멱등성 키를 생성해 서버로 전송하고, 서버는 이를 기준으로 중복 요청을 필터링하는 방법입니다.

하지만 이 방법은 클라이언트측(제휴사)에서 추가 개발 작업이 필요하여 고려하지 않았습니다.

 

2. DB Unique Key 적용

테이블에 UNIQUE KEY 제약 조건을 추가해 중복 데이터를 방지하는 방법도 고려했습니다.

하지만 API 요청 시 사용되는 제휴사 코드 값과 제휴사 회원일련번호는 일부 제휴사에서 재활용하는 경우가 있어, Unique Key로 활용할 수 없었습니다.

 

3. Redis 분산 락

Redis를 활용한 분산 락은 강력한 방법이지만, 이 API의 호출량이 많지 않아 오버 엔지니어링으로 판단했습니다.

 

4. MySQL Named Lock

MySQL이 제공하는 Named Lock 기능은 외부 시스템을 추가로 도입할 필요 없이, SQL 쿼리만으로도 락을 제어할 수 있는 간단한 방식입니다. Named Lock을 사용하면 특정 이름의 락을 획득하거나 해제할 수 있으며, 이를 통해 동시 요청을 효과적으로 제어할 수 있습니다.

결론적으로 MySQL Named Lock을 활용하기로 결정했습니다.

 

MySQL Named Lock 적용 시 주의할 점

1. 세션 관리

MySQL Named Lock은 획득(GET_LOCK)해제(RELEASE_LOCK) 작업이 동일한 DB 세션에서 이루어져야 합니다.

Spring의 JdbcTemplate이나 JPA는 트랜잭션이 없는 경우 쿼리 실행 시마다 새로운 DB 커넥션을 사용할 가능성이 높아, 이를 직접 관리해야 했습니다.

 

2. 락 이름 제한

MySQL 5.7 이상에서는 Named Lock 이름이 최대 64자로 제한됩니다.

이를 해결하기 위해, 요청 데이터(Request Body)를 기반으로 생성한 문자열을 SHA-256 해싱하여 고유한 락 이름으로 사용했습니다.

 

3. 락 점유 여부 확인

MySQL이 제공하는 락 관련 함수 중에는 IS_FREE_LOCK 이라는 락 점유 여부를 확인하는 함수가 있는데요, 이 함수를 활용하는 것은 실효성이 없습니다. 확인 후 락을 획득하는 사이에 다른 요청이 락을 점유할 가능성이 있기 때문입니다. 따라서 바로 GET_LOCK으로 락을 요청하는 방식을 선택했습니다.

 

구현 방법

NamedLockService

Named Lock을 획득하고 해제하는 로직을 다음과 같이 구현했습니다:

@Component
public class NamedLockService {

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

    public void acquireLock(Connection connection, String lockName, int timeoutSeconds) {
        try (PreparedStatement stmt = connection.prepareStatement(GET_LOCK)) {
            stmt.setString(1, lockName);
            stmt.setInt(2, timeoutSeconds);
            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next() && rs.getInt(1) == 1) {
                    return; // 락 획득 성공
                }
                throw new RequestInProgressException(); // 락 점유 중
            }
        } catch (SQLException e) {
            throw new RuntimeException("Named Lock 획득 실패", e);
        }
    }

    public void releaseLock(Connection connection, String lockName) {
        try (PreparedStatement stmt = connection.prepareStatement(RELEASE_LOCK)) {
            stmt.setString(1, lockName);
            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next() && rs.getInt(1) == 1) {
                    return; // 락 해제 성공
                }
                throw new RuntimeException("Named Lock 해제 실패");
            }
        } catch (SQLException e) {
            throw new RuntimeException("Named Lock 해제 중 오류 발생", e);
        }
    }
}

 

JdbcTemplate 사용을 고려했었는데요, JdbcTemplate 특성상 GET_LOCK 을 수행하고 트랜잭션이 종료되면 커넥션을 반환하게 되고, 추후 RELEASE_LOCK 을 수행할 때 다른 커넥션을 갖고와 버리면 정상적으로 락 해제가 되지 않기 때문에 사용하지 않았습니다.

 

NamedLockService 호출부

Interceptor 기반 접근

NamedLockService를 호출하는 부분은 처음에는 Spring의 Interceptor를 사용해 API 요청 전후로 락을 제어하는 방법을 시도했습니다.

Interceptor의 preHandle 메서드에서 락을 획득하고, afterCompletion 메서드에서 락을 해제하도록 구현했습니다.

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    String requestBody = getRequestBody(request);
    String lockName = cipherUtils.encryptSHA256(requestBody);

    namedLockService.acquireLock(lockName, 0); // 락 획득
    return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    String requestBody = getRequestBody(request);
    String lockName = cipherUtils.encryptSHA256(requestBody);

    namedLockService.releaseLock(lockName); // 락 해제
}

 

하지만 Interceptor 방식은 락의 획득과 해제를 동일한 커넥션에서 처리해야 하는 제약을 만족시키기 어려웠습니다. 따라서 커넥션을 받아와서 하나의 메서드에서 처리할 수 있는 방법을 고민해보았습니다.

AOP 기반 접근

Interceptor 방식의 한계를 극복하기 위해, AOP(Aspect-Oriented Programming)를 활용해 비즈니스 로직 실행 전후로 락 로직을 넣었습니다.

@Around("@annotation(com.yoon1fe.lock.PreventDuplicateRequest)")
public Object preventDuplicateRequest(ProceedingJoinPoint joinPoint) throws Throwable {
  Object[] args = joinPoint.getArgs();

  String lockName = cipherUtils.encryptSHA256(ObjectMapperUtils.toJson(args[0]));

  try(Connection connection = dataSource.getConnection()) {
    try {
      namedLockService.acquireLock(connection, lockName, 0);   // 락 획득
      return joinPoint.proceed();
    } finally {
      try {
        namedLockService.releaseLock(connection, lockName);    // 락 해제
      } catch (Exception e) {
        log.warn("[MySQL Named Lock] lock 해제 중 예외 발생", e);
      }
    }
  }
}

 

테스트

그럼 거의 동시에 호출되는 케이스를 어떻게 테스트할 수 있을까요?

먼저 테스트 코드에서는 각기 다른 스레드가 각각 커넥션을 따로 갖고, 첫 번째 스레드가 락을 획득하고 두 번째 스레드가 동일한 락을 거의 동시에 획득하려고 시도하는 로직을 작성했습니다.

추가로 API 호출 테스트는 JMeter 를 활용해서 동시 호출해서 재현했습니다.

 

 

 

 

 

Reference

https://dev.mysql.com/doc/refman/8.4/en/locking-functions.html

https://dev.mysql.com/doc/refman/5.7/en/locking-functions.html

https://techblog.woowahan.com/2631/

'Dev' 카테고리의 다른 글

Lombok @Builder.Default  (0) 2022.02.16
[Test][JUnit 4, 5] @Before, @BeforeClass, @BeforeEach, @BeforeAll  (3) 2021.03.29