서비스를 운영하다 보면 평소엔 멀쩡하던 코드가 트래픽이 몰리는 순간 갑자기 이상하게 동작하는 일을 겪게 됩니다. 한정 수량 이벤트나 선착순 쿠폰 같은 상황이 대표적인데요. 분명히 재고가 1개밖에 없는데 주문은 2건이 성공해서 "재고 -1"이라는 물리법칙 위반 상태가 만들어지기도 합니다.
이런 현상의 범인이 바로 동시성 이슈(Concurrency Issue)입니다. 이번 글에서는 동시성 문제가 왜 생기는지부터, 이를 막는 방법을 애플리케이션 레벨, 데이터베이스 레벨, Redis 분산락까지 차근차근 정리해 보겠습니다. 백엔드 개발자라면 한 번쯤은 꼭 부딪히게 되는 주제이니 끝까지 읽어두면 분명 써먹을 일이 생깁니다.
동시성 이슈는 왜 생길까
상황을 단순하게 만들어 보겠습니다. 상품의 재고를 조회한 뒤, 1개를 차감하는 아주 평범한 로직이 있다고 가정해 봅시다.
- 현재 재고를 조회한다 (read)
- 재고를 1 감소시킨다 (modify)
- 변경된 값을 저장한다 (write)
혼자 쓸 때는 아무 문제가 없습니다. 문제는 거의 동시에 두 개의 요청이 들어왔을 때 발생합니다. 재고가 1개 남은 시점에 요청 A와 요청 B가 거의 같은 타이밍에 도착했다고 해보죠.
- 요청 A가 재고를 조회한다 → 재고는 1
- 요청 B도 (A가 아직 저장하기 전에) 재고를 조회한다 → 재고는 똑같이 1
- 요청 A가 재고를 감소시켜 0으로 저장한다
- 요청 B도 자기가 본 값 1을 기준으로 감소시켜 0으로 저장한다
결과적으로 1개뿐인 재고에 두 건의 주문이 모두 성공해 버립니다. 이처럼 "읽고-수정하고-쓰는" 작업 사이에 다른 요청이 끼어들면서 데이터 정합성이 깨지는 것이 동시성 이슈의 본질입니다.
이걸 막으려면 공유 자원에 접근하는 특정 구간에서는 한 번에 하나의 요청만 처리되도록 출입을 통제해야 합니다. 이때 등장하는 개념이 바로 락(Lock)입니다. 화장실은 하나인데 사람은 많을 때 "사용 중" 표시를 거는 것과 똑같은 원리라고 생각하면 됩니다.
1. 애플리케이션 레벨에서 락 걸기
가장 먼저 떠올릴 수 있는 방법은 언어 차원에서 제공하는 스레드 동기화 기능을 쓰는 것입니다. Java라면 synchronized 키워드, Kotlin이라면 @Synchronized 어노테이션으로 메서드 전체나 특정 블록에 락을 걸 수 있습니다.
// Kotlin에서 메서드 단위로 동시성을 제어하려면 @Synchronized를 붙인다
@Synchronized
fun decrease(id: Long, quantity: Long) {
val stock: Stock = stockRepository.findByIdOrNull(id)
?: throw IllegalArgumentException("재고 정보를 찾을 수 없습니다.")
stock.decrease(quantity)
}
이렇게 하면 같은 인스턴스 안에서는 한 번에 하나의 스레드만 decrease에 진입할 수 있습니다.
치명적인 한계
문제는 이 락이 하나의 프로세스(JVM) 안에서만 유효하다는 점입니다. 요즘 운영 환경은 거의 대부분 서버를 여러 대 띄우는 다중 서버, 스케일 아웃 구조죠. 서버가 2대, 3대로 늘어나면 각 프로세스는 서로의 락을 전혀 알지 못합니다. 그래서 서버 A와 서버 B가 동시에 같은 재고를 건드리는 순간 synchronized는 무력해집니다.
정리하면 단일 서버에서는 쓸 수 있지만 분산 환경에서는 답이 아니라는 뜻입니다.
2. 데이터베이스에서 락 제어하기
애플리케이션이 여러 개여도, 결국 모두가 바라보는 데이터베이스는 한 곳입니다. 그렇다면 락을 DB 차원에서 걸어버리면 서버가 몇 대든 상관없겠죠. 크게 비관적 락과 낙관적 락 두 가지 전략이 있습니다.
2.1 비관적 락 (Pessimistic Lock)
이름 그대로 "충돌이 자주 일어날 거야"라고 비관적으로 가정하고, 데이터를 만질 때 아예 처음부터 락을 걸어버리는 방식입니다. 한 트랜잭션이 해당 row를 점유하고 있으면 다른 트랜잭션은 그 row가 풀릴 때까지 대기해야 합니다.
대부분의 RDBMS(MySQL, PostgreSQL, Oracle 등)는 SELECT ... FOR UPDATE 구문을 제공합니다. 이 쿼리로 조회한 row는 해당 트랜잭션이 커밋(또는 롤백)될 때까지 다른 트랜잭션이 건드리지 못하게 잠깁니다.
JPA를 쓴다면 @Lock 어노테이션으로 간단하게 적용할 수 있습니다.
interface StockRepository : JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findStockById(id: Long): Stock?
}
락 모드는 보통 두 가지를 사용합니다.
- PESSIMISTIC_WRITE: SELECT ... FOR UPDATE가 실행됩니다. 배타적 락(exclusive lock)이라 읽기와 쓰기를 모두 막습니다.
- PESSIMISTIC_READ: SELECT ... FOR SHARE가 실행됩니다. 공유 락(shared lock)이라 다른 트랜잭션의 쓰기만 막고 읽기는 허용합니다.
장점은 충돌이 잦은 상황에서 데이터 정합성을 확실하게 보장한다는 것입니다. 다만 단점도 분명합니다. 여러 테이블이 조인되는 복잡한 쿼리에서는 데드락(교착 상태)이 발생할 위험이 있고, 잦은 락은 곧 대기 시간 증가로 이어져 전체 처리량(throughput)을 떨어뜨립니다. 충돌이 거의 없는데도 매번 락을 걸면 그냥 성능만 손해 보는 셈이죠.
2.2 낙관적 락 (Optimistic Lock)
낙관적 락은 분위기가 정반대입니다. "충돌은 어차피 잘 안 일어날 거야"라고 낙관적으로 보고, 실제 DB 락은 걸지 않습니다. 대신 테이블에 버전(version) 컬럼을 하나 두고, 데이터를 수정할 때 버전이 내가 읽었던 값과 같은지 확인합니다.
동작 방식은 이렇습니다.
- 데이터를 조회하면서 현재 버전 값도 함께 읽어온다.
- 수정 시 UPDATE ... WHERE id = ? AND version = ? 형태로, 내가 읽은 버전이 그대로일 때만 업데이트하고 버전을 1 올린다.
- 그 사이 다른 트랜잭션이 먼저 값을 바꿔 버전이 올라가 있다면, 조건에 맞는 row가 없어 업데이트가 0건으로 실패한다.
- 업데이트가 실패하면 충돌로 판단해 롤백하거나 재시도한다.
JPA에서는 엔티티 필드에 @Version을 붙이면 이 과정을 알아서 처리해 주고, 충돌이 나면 OptimisticLockException을 던집니다.
@Entity
class Stock(
@Id @GeneratedValue
val id: Long = 0,
var quantity: Long,
) {
@Version
var version: Long = 0
}
장점은 실제 락을 잡지 않으니 락 경합이 없고, 충돌이 드문 환경에서는 성능이 훨씬 좋다는 점입니다. 단점은 명확합니다. 막상 충돌이 자주 발생하면 그때마다 롤백과 재시도가 반복되면서 오히려 더 느려질 수 있습니다. 따라서 재시도 로직을 별도로 잘 설계해 두어야 합니다.
3. Redis를 활용한 분산락
DB 락도 좋지만, 모든 부하를 DB에 몰아주면 데이터베이스가 병목이 되기 쉽습니다. 그래서 별도의 빠른 저장소인 Redis에 락 역할을 위임하는 방법이 널리 쓰입니다. Redis는 외부 저장소이기 때문에 다중 서버 환경에서도 모든 서버가 같은 락을 공유할 수 있습니다.
3.1 SETNX를 이용한 스핀락
SETNX는 "SET if Not eXists"의 약자로, 키가 존재하지 않을 때만 값을 세팅하는 명령입니다. 이 특성을 락으로 활용할 수 있습니다. 특정 키를 락처럼 쓰고, 키 세팅에 성공하면 락 획득, 이미 누가 키를 잡고 있으면 획득 실패로 보는 식이죠.
SET stock-id "lock" NX EX 3
위 명령은 stock-id 키가 없을 때만 "lock" 값을 넣고, 3초 뒤 자동으로 만료시킵니다. 만료 시간(EX)을 함께 거는 이유는, 락을 잡은 서버가 도중에 죽어버려도 일정 시간이 지나면 락이 풀리도록 안전장치를 두기 위함입니다. 참고로 SETNX 단독 명령은 deprecated 되었으니, 위처럼 SET 명령에 NX, EX 옵션을 함께 쓰는 방식이 권장됩니다.
획득에 실패하면 잠깐 쉬었다가 다시 시도하는 스핀락(spin lock) 형태로 구현합니다.
@Component
class RedisLockRepository(
private val redisTemplate: RedisTemplate<String, String>,
) {
fun lock(key: Long): Boolean {
return redisTemplate.opsForValue()
.setIfAbsent(key.toString(), "lock", Duration.ofSeconds(3L)) ?: false
}
fun unlock(key: Long): Boolean {
return redisTemplate.delete(key.toString())
}
}
@Service
class RedisLockStockService(
private val redisLockRepository: RedisLockRepository,
) {
fun decrease(id: Long, quantity: Long) {
// 락을 잡을 때까지 100ms 간격으로 재시도
while (redisLockRepository.lock(id).not()) {
TimeUnit.MILLISECONDS.sleep(100L)
}
try {
// 재고 감소 로직 수행
} finally {
redisLockRepository.unlock(id)
}
}
}
구현이 직관적이라는 게 장점입니다. 다만 락을 얻을 때까지 계속 Redis에 요청을 날리기 때문에, 대기하는 요청이 많아지면 Redis 서버에 불필요한 부하를 주게 됩니다. 식당 앞에서 "자리 났어요?"를 0.1초마다 물어보는 손님이 수백 명 있다고 상상하면 됩니다.
3.2 Redisson을 사용한 분산락
Redisson은 Redis 기반 분산락을 효율적으로 다룰 수 있게 해주는 오픈소스 라이브러리입니다. 스핀락의 단점인 "계속 물어보기" 문제를 우아하게 풀어줍니다. 핵심은 pub/sub과 Lua 스크립트 두 가지입니다.
pub/sub 방식은 이렇습니다. 락을 얻지 못한 스레드는 무작정 재시도하는 대신, 특정 채널을 구독(subscribe)하며 조용히 기다립니다. 그러다 락을 잡고 있던 쪽이 작업을 끝내고 unlock 하면서 채널에 메시지를 발행(publish)하면, 그제야 대기하던 스레드가 깨어나 락 획득을 시도합니다. 덕분에 쓸데없는 폴링 요청이 사라져 Redis 부하가 크게 줄어듭니다.
또한 Redisson은 lock과 unlock에 필요한 여러 명령을 Lua 스크립트로 묶어 원자적(atomic)으로 실행합니다. 예를 들어 unlock 과정에서는 락의 재진입 카운터를 1 감소시키고, 그 값이 0이 되면 키를 삭제한 뒤 채널에 메시지를 publish 하는 일련의 작업을 한 번에 처리합니다. 중간에 다른 명령이 끼어들 틈이 없으니 안전합니다.
@Service
class RedissonLockStockFacade(
private val stockService: StockService,
private val redissonClient: RedissonClient,
) {
private val log = LoggerFactory.getLogger(this::class.java)
fun decrease(id: Long, quantity: Long) {
val lock: RLock = redissonClient.getLock(id.toString())
try {
// waitTime 5초까지 락 획득을 기다리고, 획득 후 leaseTime 1초 동안 점유
val available = lock.tryLock(5L, 1L, TimeUnit.SECONDS)
if (available.not()) {
log.error("lock 획득 실패")
return
}
stockService.decrease(id, quantity)
} finally {
lock.unlock()
}
}
}
tryLock(5L, 1L, TimeUnit.SECONDS)에서 첫 번째 인자는 락을 기다리는 최대 시간(waitTime), 두 번째는 락을 점유할 수 있는 시간(leaseTime)입니다. leaseTime이 지나면 락이 자동 해제되므로, 비정상 상황에서도 락이 영원히 걸려 있는 사태를 막아줍니다.
그래서 어떤 방법을 골라야 할까
각 방식은 장단점이 뚜렷해서 "무조건 이게 정답"은 없습니다. 상황에 맞게 고르는 것이 핵심입니다.
| 방법 | 다중 서버 지원 | 충돌 잦을 때 | 충돌 드물 때 | 비고 |
| synchronized | 불가 | - | - | 단일 서버 전용, 분산 환경 부적합 |
| 비관적 락 | 가능 | 안정적 | 성능 손해 | 데드락 주의, 처리량 저하 |
| 낙관적 락 | 가능 | 롤백/재시도 빈번 | 매우 우수 | 재시도 로직 필수 |
| Redis SETNX 스핀락 | 가능 | Redis 부하 | 무난 | 구현 단순, 폴링 부하 |
| Redisson 분산락 | 가능 | 효율적 | 효율적 | pub/sub로 부하 최소화 |
간단한 판단 기준을 정리하면 이렇습니다.
- 서버가 한 대뿐인 작은 서비스라면 synchronized로도 충분합니다.
- 충돌이 드물게 일어난다면 낙관적 락이 성능상 가장 유리합니다.
- 충돌이 잦고 정합성이 무엇보다 중요하다면 비관적 락이 든든합니다.
- DB 부하를 줄이면서 다중 서버 환경에서 안정적인 분산락이 필요하다면 Redisson이 가장 무난한 선택지입니다.
마무리
동시성 이슈는 평소엔 조용히 숨어 있다가 트래픽이 몰리는 가장 결정적인 순간에 터지는 무서운 존재입니다. 그래서 선착순 이벤트나 결제, 재고 관리처럼 정합성이 생명인 기능을 만들 때는 처음부터 어떤 락 전략을 쓸지 고민해 두는 게 좋습니다.
오늘 살펴본 다섯 가지 방법을 머릿속에 넣어두면, 막상 동시성 문제를 마주했을 때 당황하지 않고 "아, 이 상황엔 이걸 쓰면 되겠다"라고 판단할 수 있을 겁니다. 직접 작은 프로젝트로 각 방식을 테스트해 보는 것도 강력 추천합니다. 글로 읽는 것과 실제로 재고가 0 밑으로 떨어지는 걸 눈으로 보는 건 완전히 다른 경험이거든요.
참고자료
- 동시성 이슈를 해결하는 다양한 방법 (velog @yellowsunn): https://velog.io/@yellowsunn/동시성-이슈를-해결하는-다양한-방법
- 예제 소스코드: https://github.com/yellowsunn/learning-projects/tree/main/stock-concurrency
- MySQL SELECT FOR UPDATE: https://jinhokwon.github.io/mysql/mysql-select-for-update/
- CockroachDB SELECT FOR UPDATE: https://www.cockroachlabs.com/blog/select-for-update/
- Redis Pub/Sub 공식 문서: https://redis.io/docs/manual/pubsub/
- Redisson GitHub: https://github.com/redisson/redisson
'IT' 카테고리의 다른 글
| 2026년 6월 25일, 오늘 가장 핫한 IT 뉴스 5가지 총정리: 마이크론 어닝 서프라이즈부터 바이트댄스 AI 공습까지 (4) | 2026.06.25 |
|---|---|
| JPA 동시성 제어 완벽 정리: 낙관적 잠금(Optimistic Lock) vs 비관적 잠금(Pessimistic Lock) (3) | 2026.06.25 |
| 2026년 6월 24일 IT 뉴스 톱5: 코스피 '검은 화요일'부터 AI 모델 대전, 램마겟돈 시즌2까지 (7) | 2026.06.24 |
| 2026년 6월 22일 가장 핫한 IT 뉴스 5선: 스페이스X의 60조원 베팅부터 클로드 셧다운까지 (5) | 2026.06.22 |
| 오늘의 IT 최신 뉴스(엔비디아 38조 회사채부터 영국 청소년 SNS 금지) (4) | 2026.06.17 |