서비스를 만들다 보면 이런 고민을 하게 됩니다.
👉 “이 작업은 동시에 딱 한 명만 할 수 있어야 하는데… 어떻게 막지?”
- 같은 상품의 정산 처리
- 특정 주문의 마감 처리
- 좌석/일정 중복 예약 방지
이럴 때 필요한 게 락(Lock) 입니다. 이번 글은 왜 분산 락이 필요한지와, 가장 가볍게 시작할 수 있는 방법인 MySQL Named Lock을 설명합니다.
서버 내부에서의 동시성 제어
1) synchronized (JVM 안에서만)
- 같은 JVM 안에서 동시에 한 스레드만 코드 블록 진입.
- 간단·빠름 / ❌ 멀티 서버에서는 소용 없음.
2) 비관적 락 (DB가 먼저 막음)
- 읽을 때부터 DB 락을 잡아 다른 트랜잭션 접근 차단.
- 정합성 강함 / ❌ 대기·데드락 비용 큼, 트랜잭션이 끝나면 락 해제.
3) 낙관적 락 (충돌 시점에 잡음)
- 읽을 땐 자유, 수정 시 버전 비교로 충돌 감지 → 재시도.
- 락 오버헤드 적음 / ❌ 충돌이 잦으면 재시도 지옥.
문제: 서버가 여러 대일 때
실무는 대부분 멀티 서버 + 로드밸런싱 환경이므로, 서버 A/B가 동시에 같은 자원(예: 특정 재고)에 접근할 수 있습니다.
- synchronized는 서버 간에 공유 안 됨(JVM 내부 전용).
- 비관적·낙관적 락은 DB 행 충돌 제어까진 훌륭하지만,
- 트랜잭션이 끝나면 락이 풀리고,
- 오래 걸리는/교차 서버 조율에는 한계가 있습니다.
해답: 분산 락 (Distributed Lock)
여러 서버가 공유하는 공통 저장소에 “이 자원은 지금 사용 중” 표식을 남기고, 락을 얻은 쪽만 임계 구역을 실행합니다.
이때 공통 저장소로 Redis, ZooKeeper, MySQL 등을 사용할 수 있습니다.
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가 내부에서 하는 일 (짧게 핵심만):
- 프록시가 가로챔: 스프링이 만든 프록시가 @NamedLock 붙은 메서드 호출을 인터셉트합니다. (@Around 포인트컷)
- 키 계산(SpEL): 어노테이션의 key(예: "petfood_#petFoodId")를 SpEL로 평가해 실제 락 키를 만듭니다.
- 같은 커넥션으로 GET/RELEASE: 락 전용 DataSource에서 커넥션을 하나 빌려 GET_LOCK(key, timeoutSec)을 실행합니다. (세션=락 생명주기)
- 임계구역은 새 트랜잭션으로 짧게: 비즈니스 로직은 @Transactional(REQUIRES_NEW) 컨텍스트에서 짧게 실행하고 곧바로 커밋/롤백합니다. (락 보유시간 최소화)
- 항상 해제 보장: 결과와 무관하게 finally에서 RELEASE_LOCK(key) 실행(획득 성공 시에만).
- 반납: 락 커넥션을 풀에 반납하고, 원래 메서드의 반환값을 그대로 돌려줍니다.
왜 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은 세션 단위 이름 락으로, 가장 가볍게 시작할 수 있는 선택지.
- 반드시 락 전용 커넥션 풀 분리, 타임아웃/백오프 설계를 권장.
'Spring > 동시성 제어' 카테고리의 다른 글
| ReentrantLock의 이해와 활용 (0) | 2025.12.03 |
|---|---|
| CAS(Compare-And-Swap) 이해하기 (0) | 2025.11.06 |
| Lettuce를 활용한 Redis 분산 락 구현하기 (0) | 2025.10.18 |
| Spring 낙관적 락 (Optimistic Lock) (0) | 2025.09.08 |
| Spring 비관적 락 (Pessimistic Lock) (0) | 2025.09.08 |