Spring/동시성 제어

MySQL Named 락과 분산 락 이해하기

kyoooooong 2025. 10. 11. 15:19

서비스를 만들다 보면 이런 고민을 하게 됩니다.

👉 “이 작업은 동시에 딱 한 명만 할 수 있어야 하는데… 어떻게 막지?”

  • 같은 상품의 정산 처리
  • 특정 주문의 마감 처리
  • 좌석/일정 중복 예약 방지

이럴 때 필요한 게 락(Lock) 입니다. 이번 글은 왜 분산 락이 필요한지와, 가장 가볍게 시작할 수 있는 방법인 MySQL Named Lock을 설명합니다.

 

 


서버 내부에서의 동시성 제어

1) synchronized (JVM 안에서만)

  • 같은 JVM 안에서 동시에 한 스레드만 코드 블록 진입.
  • 간단·빠름 / ❌ 멀티 서버에서는 소용 없음.

2) 비관적 락 (DB가 먼저 막음)

  • 읽을 때부터 DB 락을 잡아 다른 트랜잭션 접근 차단.
  • 정합성 강함 / ❌ 대기·데드락 비용 큼, 트랜잭션이 끝나면 락 해제.

3) 낙관적 락 (충돌 시점에 잡음)

  • 읽을 땐 자유, 수정 시 버전 비교로 충돌 감지 → 재시도.
  • 락 오버헤드 적음 / ❌ 충돌이 잦으면 재시도 지옥.

 

 


문제: 서버가 여러 대일 때

실무는 대부분 멀티 서버 + 로드밸런싱 환경이므로, 서버 A/B가 동시에 같은 자원(예: 특정 재고)에 접근할 수 있습니다.

  • synchronized는 서버 간에 공유 안 됨(JVM 내부 전용).
  • 비관적·낙관적 락은 DB 행 충돌 제어까진 훌륭하지만,
    • 트랜잭션이 끝나면 락이 풀리고,
    • 오래 걸리는/교차 서버 조율에는 한계가 있습니다.

 

 


해답: 분산 락 (Distributed Lock)

여러 서버가 공유하는 공통 저장소에 “이 자원은 지금 사용 중” 표식을 남기고, 락을 얻은 쪽만 임계 구역을 실행합니다.

이때 공통 저장소로 RedisZooKeeperMySQL 등을 사용할 수 있습니다.

 

 


MySQL Named Lock이란?

MySQL이 제공하는 이름 기반 사용자 락입니다.

  • 다른 세션은 해제될 때까지 대기(또는 타임아웃).
    • "Product:100" 같은 문자열 키로 락을 잡으면, 같은 이름은 단 하나의 세션만 획득.

 

핵심 특징 (생명주기)

  • 트랜잭션과 무관: 커밋/롤백과 관계없이 락은 세션(커넥션)에 종속됩니다.
  • 직접 해제 필요RELEASE_LOCK() 호출하거나 커넥션이 끊기면 자동 해제.
  • 세션 단위: 같은 세션은 중복 획득 안 되고, 다른 세션은 대기/실패.

⚠️ 그래서 커넥션 풀 분리가 중요합니다.비즈니스 커넥션과 락 커넥션을 섞으면, 풀 반납 시 의도치 않은 해제가 발생할 수 있어요.

 

주요 함수

  • GET_LOCK(name, timeoutSec) → 1(성공) / 0(타임아웃) / NULL(에러)
  • RELEASE_LOCK(name) → 1(해제됨) / 0(내 세션 아님) / NULL(없음)
  • RELEASE_ALL_LOCKS() → 현재 세션이 가진 모든 락 해제

 

 


장단점 요약

장점

  • 가볍다: SQL 한 줄로 분산 락 효과.
  • 인프라 무게↓: Redis/ZooKeeper 없이 DB만 공유해도 동작.
  • 세션 종료 시 자동 해제(장·단점 모두).

 

단점/주의

  • 세션 기반 리스크: 커넥션이 죽으면 락도 풀림.
  • 커넥션 풀 분리 필수: 락 전용 DataSource 권장.
  • MySQL 종속: DB 교체 시 코드를 갈아야 함.
  • 공정성(Fairness) 보장 안 함: 굶주림(starvation) 가능 → 타임아웃/백오프 주의

 

 


코드: AOP + @NamedLock 선언형 구현

목표: 서비스 메서드에 @NamedLock만 붙이면 AOP가 락 획득 → (REQUIRES_NEW 트랜잭션에서) 비즈니스 실행 → 락 해제를 자동 처리합니다. 개발자는 비즈니스 로직에만 집중하면 됩니다.

 

 

AOP가 내부에서 하는 일 (짧게 핵심만):

  1. 프록시가 가로챔: 스프링이 만든 프록시가 @NamedLock 붙은 메서드 호출을 인터셉트합니다. (@Around 포인트컷)
  2. 키 계산(SpEL): 어노테이션의 key(예: "petfood_#petFoodId")를 SpEL로 평가해 실제 락 키를 만듭니다.
  3. 같은 커넥션으로 GET/RELEASE락 전용 DataSource에서 커넥션을 하나 빌려 GET_LOCK(key, timeoutSec)을 실행합니다. (세션=락 생명주기)
  4. 임계구역은 새 트랜잭션으로 짧게: 비즈니스 로직은 @Transactional(REQUIRES_NEW) 컨텍스트에서 짧게 실행하고 곧바로 커밋/롤백합니다. (락 보유시간 최소화)
  5. 항상 해제 보장: 결과와 무관하게 finally에서 RELEASE_LOCK(key) 실행(획득 성공 시에만).
  6. 반납: 락 커넥션을 풀에 반납하고, 원래 메서드의 반환값을 그대로 돌려줍니다.

 

왜 AOP를 쓰나?

  • 동일한 락 보일러플레이트(획득/해제/타임아웃/키 파싱)를 한 곳(Aspect)에서 처리 → 선언형 + 재사용 + 안전성 상승.
  • 관찰성 중앙화: 락 대기/보유 시간, 타임아웃율 로깅·메트릭을 Aspect에서 통합 관리.

 

주의(한 줄씩):

  • Self-invocation: 같은 클래스 내부 호출엔 AOP가 안 걸려요(프록시 경유 필요).
  • 풀 분리: 비즈니스 풀과 락 전용 풀을 분리(세션 단위 락 특성).
  • 공정성 없음: FIFO 보장 X → 타임아웃 + 재시도(백오프) 정책을 함께 설계.

 

 

0) 락 전용 DataSource / JdbcTemplate (권장)

@Configuration
class LockDataSourceConfig {

    @Bean
    @ConfigurationProperties("spring.named-lock-datasource.hikari")
    fun lockHikariConfig() = com.zaxxer.hikari.HikariConfig()

    @Bean
    fun namedLockDataSource(cfg: com.zaxxer.hikari.HikariConfig): javax.sql.DataSource =
        com.zaxxer.hikari.HikariDataSource(cfg)

    @Bean
    fun lockJdbcTemplate(@Qualifier("namedLockDataSource") ds: javax.sql.DataSource) =
        org.springframework.jdbc.core.JdbcTemplate(ds)
}

 

왜 이렇게 했나

  • 락은 세션(커넥션) 단위이기 때문에, 비즈니스용 DataSource와 분리해야 풀 반납/재사용 타이밍에 의도치 않게 해제되지 않아요.
  • 락 전용 풀을 작게(예: 5~10) 잡고, 합계가 max_connections를 넘지 않게 전체 풀 사이즈를 설계하세요.
  • application.yaml 예시:
spring:
  named-lock-datasource:
    hikari:
      jdbc-url: jdbc:mysql://localhost:3306/app
      username: app
      password: app
      maximum-pool-size: 10

 

 


1) 어노테이션

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class NamedLock(
    /** SpEL. 예: "petfood_#petFoodId" */
    val key: String,
    /** 초 단위 타임아웃 (0=즉시 실패) */
    val timeoutSec: Int = 5
)

 

왜 이렇게 했나

  • 서비스 코드에서 @NamedLock(key="도메인_#식별자")로 선언형 사용을 가능하게 합니다.
  • SpEL("#paramName")로 호출 파라미터 기반 키를 안전하게 만들 수 있어요.

 

 


2) REQUIRES_NEW 러너 (임계구역 최소화 & 커밋 타이밍 명확화)

@Component
class AopTxRunner {
    @Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 1)
    fun <T> run(block: () -> T): T = block()
}

 

왜 이렇게 했나

  • 락을 잡은 뒤 임계구역을 새 트랜잭션에서 짧게 실행하고 바로 커밋합니다.
  • 중복 트랜잭션 공유로 생기는 동시성 재발을 차단하고, 락 보유 시간도 최소화합니다.
  • timeout = 1로 DB 커넥션을 오래 점유하지 않도록 합니다(환경에 따라 조정).

 

 


3) AOP 구현

@Aspect
@Component
class NamedLockAspect(
    private val lockJdbc: org.springframework.jdbc.core.JdbcTemplate,
    private val txRunner: AopTxRunner
) {
    companion object {
        private const val GET_LOCK_SQL = "SELECT GET_LOCK(?, ?)"
        private const val RELEASE_LOCK_SQL = "SELECT RELEASE_LOCK(?)"
        private const val MAX_NAME_LEN = 64
    }

    private val parser = org.springframework.expression.spel.standard.SpelExpressionParser()
    private val nameDiscoverer = org.springframework.core.DefaultParameterNameDiscoverer()

    @Around("@annotation(namedLock)")
    fun around(joinPoint: org.aspectj.lang.ProceedingJoinPoint, namedLock: NamedLock): Any? {
        val method = (joinPoint.signature as org.aspectj.lang.reflect.MethodSignature).method
        val key = resolveKey(method, joinPoint.args, namedLock.key)
        require(key.length <= MAX_NAME_LEN) { "lock name too long (<= $MAX_NAME_LEN)" }

        var acquired = false
        try {
            acquired = acquire(key, namedLock.timeoutSec)
            if (!acquired) error("NamedLock timeout: $key (${namedLock.timeoutSec}s)")

            // 비즈니스 로직은 새 트랜잭션에서 "짧게" 실행
            return txRunner.run { joinPoint.proceed() }
        } finally {
            if (acquired) release(key) // 획득했을 때만 해제
        }
    }

    private fun resolveKey(
        method: java.lang.reflect.Method,
        args: Array<Any?>,
        expr: String
    ): String {
        val paramNames = nameDiscoverer.getParameterNames(method) ?: emptyArray()
        val ctx = org.springframework.expression.spel.support.StandardEvaluationContext().apply {
            paramNames.forEachIndexed { i, n -> setVariable(n, args[i]) }
        }
        return parser.parseExpression(expr).getValue(ctx, String::class.java)
            ?: error("Key expression resolved to null: $expr")
    }

    private fun acquire(name: String, timeoutSec: Int): Boolean {
        val r: Int? = lockJdbc.queryForObject(GET_LOCK_SQL, Int::class.java, name, timeoutSec)
        return r == 1 // 1=성공, 0=타임아웃, NULL=에러
    }

    private fun release(name: String) {
        // 1=해제, 0=내 세션 아님, NULL=없음 → 여기서는 조용히 흘려보냄(경고 로깅 권장)
        lockJdbc.queryForObject(RELEASE_LOCK_SQL, Any::class.java, name)
    }
}

 

왜 이렇게 했나

  • @Around("@annotation(namedLock)")로 어노테이션이 붙은 메서드만 감싸서 락을 적용합니다.
  • SpEL로 key를 해석(예: "petfood_#petFoodId") → 파라미터명 분실 이슈를 막기 위해 DefaultParameterNameDiscoverer 사용.
  • acquired 플래그로 획득 성공 시에만 해제하여 불필요한 RELEASE_LOCK 호출을 피합니다.
  • 해제 실패(0/NULL)는 보통 치명적이지 않으므로 경고 로깅만 하고 흐르게 설계합니다.

 

 


4) 사용 예시 (서비스)

@Service
class PetFoodService(
    private val repo: PetFoodRepository
) {
    // 선언형으로 깔끔하게!
    @NamedLock(key = "petfood_#petFoodId", timeoutSec = 5)
    fun updatePetFood(petFoodId: Long, name: String) {
        val pf = repo.findById(petFoodId).orElseThrow { IllegalArgumentException("not found") }
        pf.name = name
        // JPA dirty checking → 커밋
    }
}

 

왜 이렇게 했나

  • 서비스 메서드가 업무 로직만 담고, 락 관련 코드는 AOP로 횡단 관심사 분리.
  • 동일 자원(petfood_{id})에 대해 동시에 하나의 서버/스레드만 실행되도록 보장합니다.
  • 외부 API 호출/대용량 I/O는 락 구간에서 금지(임계구역은 최대한 짧게).

 

 


정리

  • 비/낙관적 락은 DB 충돌 제어엔 좋지만, 멀티 서버 전체 원자성엔 한계 → 분산 락 필요.
  • MySQL Named Lock은 세션 단위 이름 락으로, 가장 가볍게 시작할 수 있는 선택지.
  • 반드시 락 전용 커넥션 풀 분리타임아웃/백오프 설계를 권장.