Spring Boot에서 만들 수 있는 Lock의 모든 것
Spring Boot 환경에서 사용할 수 있는 다양한 락(Lock) 구현 방법과 실제 적용 사례를 초보자도 이해할 수 있도록 체계적으로 설명합니다. 동시성 제어가 필요한 이유부터, 각종 락의 종류와 구현 방법, 실무에서의 활용 팁까지 실제 코드와 함께 다룹니다.
락(Lock)이란?
락은 여러 스레드나 프로세스가 동시에 하나의 자원에 접근할 때 데이터의 일관성과 무결성을 보장하기 위해 사용하는 동기화 도구입니다. 락의 핵심 목적은 동시성 환경에서 발생할 수 있는 데이터 손상, 중복 처리, 예기치 않은 오류(레이스 컨디션, 데드락 등)를 방지하는 데 있습니다. 락은 컴퓨터 공학의 고전적 주제이자, 실무에서 반드시 마주치는 문제이기도 합니다. Spring Boot 환경에서는 웹 요청, 배치, 이벤트 등 다양한 상황에서 락이 필요하며, 락의 종류와 구현 방법에 따라 시스템의 안정성과 성능이 크게 달라집니다.
락을 사용하는 대표적 사례로는 다음과 같은 상황이 있습니다.
- 동시에 여러 사용자가 같은 상품을 주문할 때, 재고가 음수로 내려가는 문제 방지
- 포인트 적립/차감이 동시에 일어날 때, 중복 적립 또는 차감 방지
- 배치/스케줄러가 여러 서버에서 중복 실행되는 것 방지
- 실시간 데이터 집계, 결제, 예약 시스템 등에서 데이터 정합성 확보
실무에서는 락을 잘못 사용하면 오히려 병목, 데드락, 성능 저하 등 심각한 장애로 이어질 수 있으므로, 락의 원리와 각 방식의 장단점을 이해하는 것이 중요합니다.
Spring Boot에서 락이 필요한 이유
Spring Boot는 기본적으로 멀티스레드 환경에서 동작합니다. 웹 서버는 여러 사용자의 요청을 동시에 처리하기 위해 여러 스레드를 사용합니다. 또한, 배치 작업, 이벤트 리스너, 비동기 서비스 등 다양한 동시성 상황이 발생합니다. 이때 락을 적절히 사용하지 않으면 다음과 같은 문제가 발생할 수 있습니다.
- 데이터 불일치: 동시에 여러 요청이 들어와 데이터가 꼬임
- 중복 처리: 동일한 작업이 여러 번 실행됨(예: 중복 결제, 중복 적립)
- 예기치 않은 오류: 레이스 컨디션, NullPointerException, 데이터 손상 등
실제 실무에서는 주문, 결제, 포인트 적립, 배치 작업, 예약 시스템 등에서 락이 필수적으로 사용됩니다. 예를 들어, 한정 수량 이벤트에서 여러 사용자가 동시에 주문을 시도할 때, 락이 없다면 재고가 음수로 내려가는 문제가 발생할 수 있습니다.
락의 종류와 동작 원리
락은 크게 JVM 기반 락, 데이터베이스 락, 분산락으로 나눌 수 있습니다. 각 방식은 동작 원리, 적용 범위, 성능, 복잡성에서 차이가 있습니다. 아래에서 각 락의 원리와 실전 적용 방법, 장단점, 실무에서 마주치는 문제 상황을 구체적으로 설명합니다.
1. JVM 기반 락
JVM 기반 락은 단일 인스턴스, 즉 하나의 서버 프로세스 내에서만 유효합니다. 대표적으로 synchronized
키워드와 ReentrantLock
이 있습니다. 이 방식은 코드가 단순하고, Spring Boot의 싱글 인스턴스 환경(테스트, 간단한 내부 서비스 등)에서 자주 사용됩니다. 하지만 서버가 여러 대로 늘어나거나, 분산 환경이 되면 효력이 없습니다.
Synchronized 키워드
가장 기본적인 락입니다. 메서드 또는 블록 단위로 객체의 모니터 락을 획득해 동기화합니다. 내부적으로 JVM이 관리하며, 락을 획득하지 못한 스레드는 대기하게 됩니다. 실전에서는 단일 서버의 단일 인스턴스에서만 데이터 일관성이 보장됩니다. 예를 들어, 메모리 기반 캐시, 단순 카운터, 파일 쓰기 등에 사용됩니다. 하지만 synchronized는 락의 범위를 세밀하게 제어하기 어렵고, 데드락에 취약할 수 있습니다. 또한, 락을 오랫동안 점유하면 전체 서비스 응답성이 저하될 수 있습니다.
class Counter {
private var count = 0
@Synchronized
fun increment() {
count++
}
}
ReentrantLock
락 획득/해제 시점을 명확하게 제어할 수 있습니다. tryLock, 공정성 옵션, 조건 변수 등 다양한 기능을 제공합니다. 실전에서는 락을 여러 메서드에 걸쳐 사용하거나, 복잡한 동기화가 필요한 경우에 활용합니다. 하지만 락 해제를 깜빡하면 데드락이 발생할 수 있으므로 반드시 finally 블록에서 해제해야 합니다. 초보자가 흔히 하는 실수는 예외 상황에서 unlock을 누락하는 것입니다. 또한, ReentrantLock은 synchronized보다 코드가 복잡해질 수 있습니다.
import java.util.concurrent.locks.ReentrantLock
class LockCounter {
private val lock = ReentrantLock()
private var count = 0
fun increment() {
lock.lock()
try {
count++
} finally {
lock.unlock()
}
}
}
2. 데이터베이스 락
DB 락은 여러 서버/프로세스가 동시에 같은 데이터를 접근할 때 데이터베이스의 트랜잭션과 락 기능을 활용해 동시성 문제를 해결합니다. Spring Data JPA는 비관적 락과 낙관적 락을 지원합니다. DB 락은 서버가 여러 대여도 데이터 정합성을 보장하지만, 락 범위가 넓거나 쿼리가 복잡하면 DB 부하가 급격히 증가할 수 있습니다. 실전에서는 주문, 결제, 재고 관리 등 강한 일관성이 필요한 곳에 주로 사용합니다.
비관적 락(Pessimistic Lock)
트랜잭션 내에서 select … for update를 사용해 row 단위 락을 겁니다. 다른 트랜잭션이 해당 row를 수정하려고 하면 락이 풀릴 때까지 대기합니다. 실무에서는 강한 정합성이 필요하거나, 충돌이 자주 발생하는 환경에서 사용합니다. 단점은 데드락, 대기 시간 증가, 전체 성능 저하입니다. 예를 들어, 인기 상품 재고 감소, 은행 계좌 이체 등에서 사용됩니다. 주의할 점은 트랜잭션이 길어지면 다른 작업이 모두 대기하므로, 락 범위를 최소화해야 합니다.
interface ProductRepository : JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findById(id: Long): Product?
}
낙관적 락(Optimistic Lock)
버전 필드(@Version)를 사용하여 충돌을 감지합니다. 트랜잭션 커밋 시점에 버전 값이 변경됐으면 예외를 발생시키고, 재시도 로직을 통해 일관성을 맞춥니다. 실전에서는 읽기 위주 시스템, 충돌이 드문 환경에서 적합합니다. 예를 들어, 회원 정보 수정, 게시글 수정 등에서 활용됩니다. 단점은 충돌이 자주 발생하면 재시도가 많아져 오히려 성능이 떨어질 수 있습니다.
@Entity
class Product(
@Id val id: Long,
@Version var version: Long
)
3. 분산락(Distributed Lock)
분산락은 여러 서버/프로세스가 동시에 자원을 접근할 때 사용하는 락입니다. 대표적으로 Redis, Zookeeper, DB 기반 분산락이 있습니다. 실전에서는 스케줄러 중복 실행 방지, 대규모 트래픽 처리, 마이크로서비스간 동기화 등에 활용됩니다. 분산락은 외부 인프라(예: Redis, Zookeeper)가 장애나 네트워크 이슈를 겪으면 전체 서비스에 영향을 줄 수 있으므로, 항상 장애 대비와 모니터링이 필요합니다.
Redis 기반 분산락 (Redisson)
Redisson은 Redis의 setnx, expire 명령을 조합해 락을 구현합니다. RedLock, FairLock, ReadWriteLock 등 다양한 락 타입을 지원합니다. 실전에서는 스케줄러, 배치, 주문 중복 방지 등에서 많이 사용합니다. 단점은 Redis 장애, 네트워크 분리(brain split) 상황에서 락이 풀리지 않거나, 중복 실행이 발생할 수 있다는 점입니다. Redisson은 락 만료, 자동 해제, 재시도, pub/sub 기반 이벤트 등 다양한 안전장치를 제공합니다. 락 키 네이밍, 만료 시간, 재시도 정책을 꼼꼼히 설정해야 합니다.
@Service
class RedisLockService(@Autowired val redissonClient: RedissonClient) {
fun executeWithLock(key: String, task: () -> Unit) {
val lock = redissonClient.getLock(key)
lock.lock()
try {
task()
} finally {
lock.unlock()
}
}
}
DB 기반 분산락 (MySQL Named Lock)
MySQL의 GET_LOCK/RELEASE_LOCK 함수는 네임드 락을 제공합니다. 실전에서는 별도 인프라 없이 간단히 분산락을 구현할 수 있지만, 락을 장시간 점유하면 DB 연결이 고갈될 수 있습니다. 락이 풀리지 않으면 서비스 전체가 멈출 수 있으므로, 락 획득/해제 로직을 반드시 모니터링해야 합니다.
SELECT GET_LOCK('lock_key', 10);
-- 작업 수행
SELECT RELEASE_LOCK('lock_key');
Zookeeper 기반 분산락
Zookeeper는 ephemeral/sequential node를 조합해 락을 구현합니다. 대규모 시스템, 고가용성이 필요한 환경에서 주로 사용합니다. Curator 등 오픈소스 라이브러리가 분산락 패턴을 쉽게 제공합니다. 단점은 운영 복잡성, 네트워크 지연입니다. 실전에서는 대규모 배치, 분산 트랜잭션, 리더 선출 등에 활용됩니다.
분산락은 항상 장애, 네트워크 분리, 락 만료, 재시도 정책, 모니터링 등 다양한 예외 상황을 고려해야 하며, 락이 풀리지 않거나 중복 실행이 발생하지 않도록 설계해야 합니다.
락 사용 시 주의사항
락은 동시성 문제를 해결하는 강력한 도구이지만, 잘못 사용하면 오히려 심각한 장애와 성능 저하를 유발할 수 있습니다. 다음은 실전에서 반드시 지켜야 할 핵심 주의사항입니다.
- 락 범위 최소화: 락을 거는 코드는 꼭 필요한 최소 범위로 한정해야 병목을 줄이고 데드락 가능성을 낮출 수 있습니다. 예를 들어, DB에서 데이터를 읽은 뒤, 실제로 변경이 필요한 부분만 락을 걸고, 그 외 로직은 락 외부에서 처리합니다.
- 데드락(Deadlock) 예방: 여러 자원에 락을 동시에 걸 때는 항상 동일한 순서로 락을 획득해야 합니다. 락 획득에 타임아웃을 설정하고, 대기 시간이 길어지면 예외를 던져 장애를 조기에 감지합니다.
- 락 해제 누락 방지: try-finally 또는 try-with-resources를 사용해 예외가 발생해도 반드시 락이 해제되도록 합니다. 실전에서는 락 해제 누락이 가장 흔한 장애 원인이므로, 항상 주의해야 합니다.
- 분산락의 네트워크/인프라 장애 대비: Redis, Zookeeper 등 외부 인프라 장애 시 락이 풀리지 않거나 중복 실행이 발생할 수 있습니다. 락 만료, 재시도, 장애 복구 로직, 모니터링을 반드시 구현해야 합니다.
- 트랜잭션과 락 결합 주의: 트랜잭션이 길어지면 락 점유 시간도 길어져 전체 성능이 저하될 수 있습니다. 트랜잭션과 락 범위를 분리하거나, 필요한 최소 구간에서만 락을 사용하세요.
실전 문제 상황과 해결법
- 주문 중복/재고 음수: 락 없이 동시 처리 시 실무에서 빈번하게 발생. JVM 락은 단일 서버에서만, DB 락/분산락은 멀티 서버 환경에서 필수입니다. 재고 감소, 포인트 적립 등 강한 정합성이 필요한 로직에는 반드시 락을 사용하세요.
- 배치/스케줄러 중복 실행: 여러 서버에서 동일 배치가 중복 실행되는 문제는 ShedLock, Redisson 등 분산락으로 해결합니다. 락 해제 누락, 장애 시 중복 실행 방지 로직도 반드시 구현해야 합니다.
- 포인트 적립/차감 동시성: 낙관적 락으로 충돌을 감지하고 재시도하거나, 비관적 락으로 강제 직렬화합니다. 실전에서는 재시도 정책, 충돌 예외 처리, 사용자 알림 등도 함께 설계해야 합니다.
- 데드락: 락 획득 순서 일관성, 타임아웃, 락 범위 최소화로 예방합니다. 락이 여러 개 필요한 경우 항상 동일한 순서로 락을 걸고, 대기 시간이 길면 즉시 예외를 발생시켜 장애를 조기에 감지하세요.
- 락 해제 누락: finally 블록, try-with-resources 사용을 습관화하세요. 락 해제가 누락되면 서비스 전체가 멈출 수 있습니다.
- 분산 환경 장애: Redis, Zookeeper 등 분산락 인프라 장애 시, 락 만료/재시도/복구 로직을 반드시 구현하고, 장애 발생 시 자동 알림 및 수동 해제 방법도 마련하세요.
오픈소스와 Spring Boot 실전 패턴
- Redisson: Redis 기반 분산락 구현체로, 자동 만료, 재시도, pub/sub 기반 이벤트를 제공합니다. 실전에서는 락 키 네이밍, 만료 시간, 재시도 정책을 꼼꼼히 설정해야 하며, 장애 발생 시 자동 복구 로직도 필요합니다.
- ShedLock: DB/Redis 기반 분산 스케줄러 락 라이브러리로, 배치/스케줄러 중복 실행 방지에 특화되어 있습니다. Spring Boot와 쉽게 연동 가능하며, 락 점유 시간, 해제 실패 등도 모니터링해야 합니다.
- Curator: Zookeeper 기반 분산락 라이브러리로, 대규모 시스템에서 안정적으로 사용됩니다. 리더 선출, 분산 트랜잭션, 대규모 배치 등에 적합합니다.
- Spring Data JPA: @Lock 어노테이션으로 비관적/낙관적 락을 지원합니다. 실전에서는 트랜잭션 범위, 예외 처리, 재시도 정책, 모니터링이 중요합니다.
모니터링과 트러블슈팅
- 락 획득/해제 로그: 락의 획득, 해제, 대기, 실패, 타임아웃 등 모든 이벤트를 로그로 남겨야 합니다. 장애 발생 시 원인 분석에 필수입니다.
- 락 대기/점유 시간 측정: 락 대기 시간이 비정상적으로 길어지면 장애 징후일 수 있으니, 별도 모니터링 지표로 관리하세요.
- 분산락 인프라 모니터링: Redis, Zookeeper, DB 등 외부 인프라의 상태를 실시간으로 모니터링하고, 장애 발생 시 즉시 알림이 오도록 구성하세요.
- 락 관련 대시보드: 락 점유 시간, 데드락 발생 수, 중복 실행, 락 해제 누락 등 주요 지표를 대시보드로 시각화해 운영팀이 쉽게 확인할 수 있도록 합니다.
- 장애 발생 시 대응: 락 관련 로그, 인프라 상태, 스레드 덤프, 락 키 상태 등을 종합적으로 분석해 원인을 파악하고, 필요시 수동으로 락을 해제할 수 있는 절차도 마련하세요.
초보자를 위한 실전 조언
- 락은 만능이 아니며, 잘못 사용하면 오히려 장애의 원인이 됩니다. 락 없이 해결할 수 있는 구조(이벤트 소싱, CQRS, 비동기 메시지 등)도 적극적으로 고려하세요.
- 락 범위 최소화, 예외 상황 대비, 모니터링 습관화가 중요합니다. 락을 남용하지 말고, 꼭 필요한 곳에만 사용하세요.
- 오픈소스의 내부 구현 원리와 한계를 이해하고, 공식 문서/레퍼런스를 적극 참고하세요. 실전에서는 공식 문서, 커뮤니티 사례, 장애 사례를 꾸준히 학습하는 것이 중요합니다.
- 락 관련 장애는 반드시 사전 테스트, 장애 복구 시나리오, 자동화된 모니터링/알림 체계를 구축해 예방하세요.
참고 자료
Spring Boot에서 락은 데이터 정합성과 장애 예방의 핵심 도구입니다. 락의 종류, 원리, 실전 패턴을 이해하고, 상황에 맞는 락을 선택해 안전하고 효율적인 시스템을 설계하세요. 궁금한 점이 있으면 공식 문서나 레퍼런스를 참고하거나, 댓글로 질문해 주세요.