[Spring] MySQL을 이용한 중복 발급 방지와 동시성 해결 이야기 (멱등성, 분산락)

2025. 9. 10. 22:35·Backend

안녕하세요.

 

최근에 업무를 하면서 계산서가 중복으로 발급이 되는 문제를 발견했습니다.

 

다행히 PHP에서 Spring 기반으로 마이그레이션을 하고 있는 단계라 실서버 배포가 되진 않았고, 기존 PHP 실서버에선 중복 발급 사례는 없었지만 이대로 배포가 된다면 충분히 중복으로 발급하여 포인트가 차감되는 문제가 발생할 수 있었습니다.

 

더불어서 스프링 환경에서 동시성 문제도 발견했는데, 낙관적락과 비관적락은 사내 DB 구조상 적용해도 큰 의미가 없어서 이를 어떻게 해결했는지도 같이 이야기 해보고자 합니다. 

 

참고로 현재 팀 내에서 MySQL 5.x 버전을 사용하고, 서비스 상황상 아직까지 Redis는 도입할 필요가 없다고 생각하여 작업 또한 MySQL로 진행했습니다.

 

부족한 점이 있으면 피드백 부탁드리겠습니다!


1. 이중 발급으로 인한 포인트 중복 차감 문제

1-1.  문제 지점

저희 서비스에선 아래와 같이 세금계산서를 발급할 때 포인트를 차감하는 구조로 로직이 이뤄져있습니다.

fun issueDocument(...) {
    // 1. 계산서 검증
    documentValidatorPort.validate(...)
    
    // 2. 그 외 작업...
    doSomething()
    
    // 3. 포인트 차감 (외부 API 호출)
    pointUseRequestPort.usePoint(...)
    
    // 4. 저장
    documentPersistencePort.save(...)
}

 

사용자가 요청을 보냈을 때 딱 한 건만 온다면 좋지만, 네트워크 문제가 생겨 여러 번 요청을 보내는 경우에는 같은 건에 대해서 중복으로 요청이 오게 됩니다.

 

이런 경우 포인트가 중복 차감이 되고 저장 혹은 발급도 중복으로 이뤄지게 됩니다.

 

아무래도 서비스에서 포인트는 돈과 직접 연관되어 있기 때문에 문제가 생긴다면 서비스의 신뢰도가 낮아질 수 있다고 생각합니다. 또한 같은 이유로 세금계산서 발급 문제도 딱 한 번 정확하게 처리가 되어야 합니다.

 

 

1-2. 해결 방법

이를 해결하기 위해서 어떤 방법을 적용하면 좋을지에 대해 많은 고민을 했습니다.

 

사용자가 정말로 여러 번 요청하고 싶어서 요청한 것인지, 아니면 의도하지 않았는데 여러 번 요청이 온 것인지도 서버 측에선 알 수가 있어야 했습니다.

 

세금계산서 서비스 특성상 문서의 payload가 동일하게 이뤄질 수도 있기 때문에 이 둘을 파악하는 건 쉽지 않았습니다.

 

그래서 이 두 가지를 구별하기 위해서 프론트엔드 측에 요청을 드려 사용자가 문서 작성(or 발급)페이지에 접속 시점에 최초 uuid를 생성할 수 있도록 부탁을 드렸습니다. (작성 후 발급 프로세스)

 

payload가 같을 수 있는 세금계산서 특성상 프론트엔드측으로 uuid를 받으면 작성 페이지에서 여러 번 중복 요청이 갔는지 확인할 수 있는 수단이 될 수 있었습니다.

 

따라서 이 uuid 값을 기준으로 최초 요청 건에 대해서 MySQL의 NamedLock 획득 시도를 하고, 이후 중복으로 온 요청에서는 동일 이름의 Lock을 획득하지 못하도록 하면서 중복 처리를 막을 수 있었습니다.

위에 사진과 같이 1번 요청에선 MySQL에서 NamedLock을 얻을 때, uuid 이름을 가진 Lock이 최초엔 없기 때문에 정상적으로 락을 획득하고 요청을 처리해서 응답을 내려줄 수 있고,

이미지 상 2번 요청과 같이 이후 들어오는 요청에 대해서는 uuid 값에 해당하는 Lock을 이미 다른 커넥션 풀에서 요청하여 소유중이기 때문에 획득하지 못했다는 응답을 받고, 서버 측에선 해당 응답을 기준으로 409 에러를 내려주며 중복 발급 문제를 해결할 수 있었습니다.

사실 엄밀히 말하면 같은 응답을 내려주는 게 멱등성 측면에선 더 적절하다고 생각합니다. 다만, 저희 서비스 같은 경우 같은 응답을 내려줄 필요는 없기 때문에, 409 에러를 내려주는 방식으로 선택을 했습니다.

만약 같은 응답을 내려줘야 한다면 { "uuid" : "response-payload" }와 같이 DB 같은 곳에 저장을 하고, 이를 조회해서 같은 응답을 내려줄 수 있을 것 같습니다. (Redis를 쓴다면 TTL을 걸고, 아니라면 스케줄러를 통해 주기적 제거)

 

 

이를 어노테이션화해서 다음과 같이 사용할 수 있습니다. (코드는 코틀린 학습 겸 복각한 거라서 "대충 이렇구나~" 정도로 참고만 해주세요.)

import java.util.concurrent.TimeUnit
import kotlin.annotation.AnnotationRetention
import kotlin.annotation.AnnotationTarget

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Idempotent(
    /**
     * 멱등키 이름
     */
    val keyName: String,
    
    /**
     * 멱등키의 TTL 시간
     */
    val ttlTime: Long = 30L,
    
    /**
     * 멱등키의 TTL 시간 단위
     */
    val timeUnit: TimeUnit = TimeUnit.SECONDS
)

 

import mu.KotlinLogging
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.reflect.MethodSignature
import org.springframework.core.annotation.Order
import org.springframework.stereotype.Component
import java.sql.SQLException

private val logger = KotlinLogging.logger {}

@Component
@Order(1)
@Aspect
class IdempotentAspect(
    private val spelExpressionManager: SpelExpressionManager,
    private val idempotentManager: IdempotentManager
) {
    
    @Around("@annotation(com.{패키지명}.Idempotent)")
    fun idempotentAspect(joinPoint: ProceedingJoinPoint): Any? {
        val signature = joinPoint.signature as MethodSignature
        val method = signature.method
        val idempotentAnnotation = method.getAnnotation(Idempotent::class.java)
            ?: throw IllegalStateException("Idempotent annotation not found")
        
        val keyName = getKeyNameParsedFromSpEL(joinPoint, idempotentAnnotation, signature)
        val isAlreadyLockAcquired = idempotentManager.isAlreadyLockAcquired(keyName)
        
        // 이미 멱등키가 획득된 경우, 중복 요청으로 간주하고 예외 처리
        if (isAlreadyLockAcquired) {
            logger.warn { 
                "중복된 요청을 이미 처리 중입니다. 해당 작업은 종료됩니다. method: ${method.name}, idempotent-key: $keyName" 
            }
            throw CustomExceptionV2(IdempotentExceptionType.IDEMPOTENT_ALREADY_EXISTS)
        }
        
        try {
            idempotentManager.tryGetIdempotentLock(
                keyName, 
                idempotentAnnotation.ttlTime, 
                idempotentAnnotation.timeUnit
            )
            joinPoint.proceed()
            idempotentManager.releaseIdempotentLock(keyName)
        } catch (e: SQLException) {
            logger.error(e) { "MySQL에서 NamedLock 멱등키 획득 혹은 반납 과정에서 실패했습니다." }
            throw CustomExceptionV2(IdempotentExceptionType.IDEMPOTENT_MANAGING_DB_COMMAND_FAILURE)
        }
        
        return null
    }
    
    private fun getKeyNameParsedFromSpEL(
        joinPoint: ProceedingJoinPoint,
        idempotentAnnotation: Idempotent,
        signature: MethodSignature
    ): String {
        // SpEL 기반으로 LockKey 변환
        val keyNameSpEL = idempotentAnnotation.keyName
        val methodArgs = joinPoint.args
        val paramNames = signature.parameterNames
        return spelExpressionManager.evaluate(keyNameSpEL, methodArgs, paramNames)
    }
}
import mu.KotlinLogging
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.dao.EmptyResultDataAccessException
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Component
import java.sql.SQLException
import java.util.concurrent.TimeUnit

private val logger = KotlinLogging.logger {}

@Component
class IdempotentManager(
    @Qualifier("aspDBNamedParameterJdbcTemplate")
    private val namedParameterJdbcTemplate: NamedParameterJdbcTemplate
) {
    
    fun isAlreadyLockAcquired(keyName: String): Boolean {
        val checkParam = MapSqlParameterSource()
            .addValue("userLockName", keyName)
        
        val result = namedParameterJdbcTemplate.queryForObject(
            CHECK_LOCK_ALREADY_ACQUIRED, 
            checkParam, 
            Boolean::class.java
        )
        return result != null
    }
    
    @Throws(SQLException::class)
    fun tryGetIdempotentLock(keyName: String, time: Long, timeUnit: TimeUnit) {
        val timeoutSeconds = timeUnit.toSeconds(time)
        
        val params = MapSqlParameterSource()
            .addValue("userLockName", keyName)
            .addValue("timeoutSeconds", timeoutSeconds)
        
        namedParameterJdbcTemplate.queryForObject(GET_LOCK, params, Boolean::class.java)
    }
    
    fun releaseIdempotentLock(keyName: String) {
        val lockNameParam = MapSqlParameterSource()
            .addValue("userLockName", keyName)
        
        try {
            namedParameterJdbcTemplate.queryForObject(RELEASE_LOCK, lockNameParam, Boolean::class.java)
        } catch (e: EmptyResultDataAccessException) {
            logger.error { "MySQL NamedLock 멱등키 반납 실패" }
            throw CustomException(IdempotentExceptionType.IDEMPOTENT_MANAGING_DB_COMMAND_FAILURE)
        }
    }
    
    companion object {
        private const val CHECK_LOCK_ALREADY_ACQUIRED = "SELECT IS_USED_LOCK(:userLockName)"
        private const val GET_LOCK = "SELECT GET_LOCK(:userLockName, :timeoutSeconds)"
        private const val RELEASE_LOCK = "SELECT RELEASE_LOCK(:userLockName)"
    }
}
import org.springframework.expression.spel.standard.SpelExpressionParser
import org.springframework.expression.spel.support.StandardEvaluationContext
import org.springframework.stereotype.Component

@Component
class SpelExpressionManager {
    
    private val parser = SpelExpressionParser()
    
    fun evaluate(
        expression: String,
        methodArgs: Array<Any?>,
        paramNames: Array<String>?
    ): String {
        val parsedExpression = parser.parseExpression(expression)
        val context = StandardEvaluationContext()
        
        paramNames?.let {
            for (i in it.indices) {
                context.setVariable(it[i], methodArgs[i])
            }
        }
        
        return parsedExpression.getValue(context, String::class.java) 
            ?: throw IllegalArgumentException("Expression evaluation returned null")
    }
}

 

 

이런 느낌의 코드이고 아래와 같이 사용하도록 만들었습니다.

@Idempotent(keyName = "#uuid ...", ttlTime = 10, timeUnit = TimeUnit.SECONDS) 
fun issueDocument(
    idempotentKey: String // 클라이언트에게 받은 UUID
    request: IssueCommand,
) = ...

 

 

구현하면서 한 가지 우려되는 부분이 있었는데요.

 

MySQL get-lock 공식문서 를 보면 5.7 버전부터 동시 잠금이 적용된다고 명시가 되어있습니다.

동시 잠금으로 같은 락을 여러 개 얻어서 멱등성이 지켜지지 않을까봐 걱정 했는데, 생각을 해보면 동시 요청이 와도 다른 쓰레드에서 각각 다른 DB 커넥션을 얻으려고 시도하기 때문에 실제 충돌할 일은 없을 거라 생각하고 진행했습니다.

 


 

2. 동시성 문제

2-1. 문제 지점

과거 작성한 글과 같이 동시성 문제를 해결하기 위해 여러 가지 방법이 있습니다.

다만, 저희 서비스에서 사용하는 DB 구조상 해당 방식들은 비효율적이라고 생각 했습니다.

 

예를들어 다음 케이스를 확인해보겠습니다.

fun updateSomething(...) {
    // 1. 비관적 락을 이용한 데이터 조회 (or 낙관락)
    var dataFromAsp = aspDBPersistencePort.findByIdForUpdate(id);
    var dataFromSub = subDBPersistencePort.findByIdForUpdate(id);
    
    // 2. 업데이트
    var dataFromAsp.updateAndCommit(...)
    var dataFromSub.updateAndCommit(...)
}

위 케이스처럼 저희 서비스에선 두 가지 DB(MySQL)를 사용하고 있습니다.

 

여러 인스턴스로 이뤄진 SubDB와 단일 인스턴스로 이뤄진 AspDB로 이뤄져있는데 하나의 트랜잭션에서 SubDB와 AspDB를 모두 사용하는 경우이면서 동시성 제어가 필요한 경우가 있습니다.

 

이때 동시성 제어를 위해 낙관적락 혹은 비관적락을 사용할 수 있습니다.

낙관적락 같은 경우 버전이 다른 경우 어떤 DB에서 실패했는지 알기 어렵고, 비관적락 같은 경우 중요한 계산서 row에 X-lock을 걸기 때문에 데드락 위험도 있어서 사용하기 조심스러웠습니다.

 

따라서 단일 인스턴스를 공유하는 DB를 통해 하나의 공유 지점을 만들고 해당 DB에서 실제 Row에 Lock을 거는 방식이 아닌 락 네임으로 추상화된 NamedLock을 사용하는 것이 좋다고 판단했습니다. 또한 해당 방식은 서비스가 고도화 되어, 서버와 DB가 분리될 때에도 쉽게 변경할 수 있을 거라 생각해서 더욱 적절하다고 생각했습니다.

 

2-2. 해결 방법

AspDB, SubDB 각각의 Lock을 거는 방식이 전체가 공유하는 단일 인스턴스로 이뤄진 AspDB에 NamedLock을 거는 방식으로 분산락을 구현했습니다.  

 

위에 멱등성 해결과 마찬가지로 Redis를 사용하지 않아 MySQL로 진행을 했습니다.

 

간단한 복각 코드로 확인하겠습니다.

import java.util.concurrent.TimeUnit
import kotlin.annotation.AnnotationRetention
import kotlin.annotation.AnnotationTarget

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class DistributedLock(
    /**
     * Lock 이름.
     */
    val keyName: String,
    
    /**
     * 락 획득을 위해 대기하는 시간
     */
    val waitTime: Long = 5L,
    
    /**
     * 락의 지속시간 (ttlTime이 지나면 락이 해제됨)
     */
    val ttlTime: Long = 30L,
    
    /**
     * 락의 TTL 단위를 설정.
     */
    val timeUnit: TimeUnit = TimeUnit.SECONDS
)
import mu.KotlinLogging
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.reflect.MethodSignature
import org.springframework.core.annotation.Order
import org.springframework.stereotype.Component

private val logger = KotlinLogging.logger {}

@Order(1)
@Component
@Aspect
class DistributedLockAspect(
    private val spelExpressionManager: SpelExpressionManager,
    private val lockManager: DistributedLockManager
) {
    
    @Around("@annotation(com.{package}.DistributedLock)")
    fun applyDistributedLock(joinPoint: ProceedingJoinPoint): Any? {
        val signature = joinPoint.signature as MethodSignature
        val annotation = signature.method.getAnnotation(DistributedLock::class.java)
            ?: throw IllegalStateException("DistributedLock annotation not found")
        
        val lockKey = getLockKeyParsedFromSpEL(joinPoint, annotation, signature)
        val lockWaitTime = annotation.waitTime
        val ttlTime = annotation.ttlTime
        val timeUnit = annotation.timeUnit
        
        // 분산락 획득 시도
        val isAvailableLock = lockManager.tryLock(lockKey, lockWaitTime, ttlTime, timeUnit)
        
        // 분산락 획득 실패 시 예외 처리 후 return
        if (!isAvailableLock) {
            logger.warn { "Lock 대기 시간이 지나 락을 획득할 수 없습니다. key: $lockKey" }
            throw CustomException(DistributedLockExceptionType.LOCK_WAIT_TIME_OUT)
        }
        
        // 락 획득 성공 후 메서드 실행
        joinPoint.proceed()
        
        // 메서드 종료 후 분산락 해제 (트랜잭션보다 락이 먼저 끝나면 정합성 문제 발생하기 때문)
        lockManager.releaseLock(lockKey)
        
        return null
    }
    
    private fun getLockKeyParsedFromSpEL(
        joinPoint: ProceedingJoinPoint,
        annotation: DistributedLock,
        signature: MethodSignature
    ): String {
        // SpEL 기반으로 LockKey 변환
        val lockKeySpEL = annotation.keyName
        val methodArgs = joinPoint.args
        val paramNames = signature.parameterNames
        return spelExpressionManager.evaluate(lockKeySpEL, methodArgs, paramNames)
    }
}
import mu.KotlinLogging
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.dao.EmptyResultDataAccessException
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Component
import java.util.concurrent.TimeUnit

private val logger = KotlinLogging.logger {}

@Component
class DistributedLockManager(
    @Qualifier("aspDBNamedParameterJdbcTemplate")
    private val namedParameterJdbcTemplate: NamedParameterJdbcTemplate
) {
    
    fun tryLock(lockName: String, lockWaitTime: Long, ttlTime: Long, timeUnit: TimeUnit): Boolean {
        val timeoutSeconds = timeUnit.toSeconds(ttlTime)
        val waitTimeLimitMillis = timeUnit.toMillis(lockWaitTime)
        val lockWaitStartTime = System.currentTimeMillis()
        
        return try {
            // 락 대기 시간까지 락 획득을 위해 반복 시도
            while (System.currentTimeMillis() - lockWaitStartTime < waitTimeLimitMillis) {
                if (!isAlreadyLockAcquired(lockName)) {
                    // 락을 획득할 수 있는 경우
                    val params = MapSqlParameterSource()
                        .addValue("lockName", lockName)
                        .addValue("timeoutSeconds", timeoutSeconds)
                    
                    namedParameterJdbcTemplate.queryForObject(GET_LOCK_SQL, params, Int::class.java)
                    return true
                }
                
                // 락을 획득할 수 없는 경우, 잠시 대기 후 재시도
                Thread.sleep(100)
            }
            
            // 락 대기 시간이 초과되는 경우
            logger.warn { "분산락 대기 시간 초과로 락 획득 실패 - lockName: $lockName" }
            false
        } catch (e: Exception) {
            logger.error(e) { "분산락 획득 실패 - lockName: $lockName" }
            throw CustomException(DistributedLockExceptionType.LOCK_MANAGING_ERROR)
        }
    }
    
    private fun isAlreadyLockAcquired(lockName: String): Boolean {
        val checkParam = MapSqlParameterSource()
            .addValue("lockName", lockName)
        
        val result = namedParameterJdbcTemplate.queryForObject(
            CHECK_LOCK_ALREADY_ACQUIRED, 
            checkParam, 
            Boolean::class.java
        )
        return result != null
    }
    
    fun releaseLock(lockName: String) {
        val lockParam = MapSqlParameterSource()
            .addValue("lockName", lockName)
        
        try {
            namedParameterJdbcTemplate.queryForObject(RELEASE_LOCK_SQL, lockParam, Boolean::class.java)
        } catch (e: EmptyResultDataAccessException) {
            logger.error(e) { 분산락 RELEASE_LOCK 실행 중 예외 발생 - lockName: $lockName" }
            throw CustomException(DistributedLockExceptionType.LOCK_MANAGING_ERROR)
        }
    }
    
     companion object {
        private const val CHECK_LOCK_ALREADY_ACQUIRED = "SELECT IS_USED_LOCK(:lockName)"
        private const val GET_LOCK_SQL = "SELECT GET_LOCK(:lockName, :timeoutSeconds)"
        private const val RELEASE_LOCK_SQL = "SELECT RELEASE_LOCK(:lockName)"
    }
}

 

코드는 이렇게 구현했습니다.

 

 

위 이미지와 같이 트랜잭션 작업이 끝나고 분산락을 반납하는 이유는 분산락을 먼저 반납하면, 메서드 전체가 끝나기 전 다시 접근할 수 있고 이 시점에서 커밋 직전 데이터를 읽어서 수정할 수 있기 때문에(== 정합성 깨짐) 분산락을 마지막에 반납했습니다.

 

또한 MySQL을 사용하여 구현하다보니 락 점유 중 락 획득 시도가 있을 때, while 문을 사용할 수 밖에 없었습니다.

 

락을 획득할 때까지 스레드가 빙빙돈다고 이를 스핀락이라고 부르는데, 이 방식의 단점은 쓰레드가 대기시간동안 락 획득을 위해 루프를 돌기 때문에 락 획득 혹은 타임아웃 시점까지 while문에서 락 획득 요청으로 인한 부하가 생길 수도 있습니다.

 

Redis에선 Redisson 라이브러리를 통해 위와 같은 스핀락 방식을 pub/sub 구조로 처리하여 보완할 수 있다고 합니다.

즉 락이 반납되는 시점(= 다른 스레드에서 락을 사용할 수 있을 때)에 "락 써도 돼~"라는 신호를 publish 해주고 대기중인 클라이언트는 이를 수신하여 대기중인 스레드가 락을 획득하는 방식입니다.

 

당장 저희 서비스에선 이정도까지는 필요 없지만, 언젠가 부하가 심해지면 도입을 고려해볼 수 있을 것 같습니다.

저작자표시 (새창열림)

'Backend' 카테고리의 다른 글

[Spring] 외부 API를 호출할 때 주의할 점에 대해서  (1) 2025.09.16
[Spring Batch] 대량 파일 마이그레이션 작업 #1 - 도입  (6) 2025.08.02
[Kafka] Kafka 개념을 훑어보고, 주의할 점에 대해서 가볍게 알아보자  (1) 2025.07.15
'Backend' 카테고리의 다른 글
  • [Spring] 외부 API를 호출할 때 주의할 점에 대해서
  • [Spring Batch] 대량 파일 마이그레이션 작업 #1 - 도입
  • [Kafka] Kafka 개념을 훑어보고, 주의할 점에 대해서 가볍게 알아보자
sosow0212
sosow0212
함께 성장하는 개발자가 되기 위해서 노력합니다.
  • sosow0212
    기록잡화점
    sosow0212
  • 전체
    오늘
    어제
    • 분류 전체보기
      • 개발 일기
      • Backend
      • AI Engineering
  • 블로그 메뉴

    • 2025년 이전 기술블로그
    • 홈
    • 글쓰기
    • 태그
    • 방명록
  • 링크

    • 25년도 이전 개발블로그
    • LinkedIn
    • Github
  • 공지사항

  • 인기 글

  • 태그

    분산락
    멱등키
    마이그레이션
    spring
    Kafka
    backend
    mysql
    멱등성
    락
    Spring Batch
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
sosow0212
[Spring] MySQL을 이용한 중복 발급 방지와 동시성 해결 이야기 (멱등성, 분산락)
상단으로

티스토리툴바