서론

동시성을 제어하기 위해 다양한 락 기법을 적용해보면서 레벨 별 성능이 다르다는 것을 알게 되었다.

 

얄팍하게나마 레벨 별 동시성 제어 기법을 알아보자.

 

왜 락의 레벨을 구분해야 할까?

 

백엔드 개발을 하다 보면 동시성 문제는 피할 수 없다.

특히 주문, 결제, 재고, 좌석 예약과 같은 도메인에서는 "누가 먼저 처리했는가" 보다 "데이터가 정확한가"가 훨씬 중요해진다.

 

처음에는 synchronized나 @Transactional만으로도 해결되는 것처럼 보인다.

하지만 서버가 늘어나고 트래픽이 증가하는 순간, 이 방식들이 아무 의미 없는 코드가 되는 경험을 하게 된다.

 

이 글에서는 락을 단순히 기술 목록이 아니라, "어디에서 동시성을 제어하느냐"라는 관점에서 정리해본다.

 

레벨은 JVM, Spring Data JPA, MySQL 기준으로 서술한다.


본론

1. JVM 레벨

가장 좁은 범위의 락이다. 단일 서버(인스턴스) 내의 멀티스레드 환경에서만 유효하다.

  • 서버가 여러 대(Scale-out)인 환경에서는 동기화가 불가능하다.

종류

  • synchronized
    • 자바의 예약어로, 메서드나 블록에 선언하여 모니터 락을 획득합니다.
    • 사용이 간편하지만, 락을 획득할 때까지 무한 대기하며 타임아웃 설정이 불가능하다.
    • public synchronized void method() {...}
  • ReentrantLock
    • java.util.concurrent.locks 패키지에서 제공한다.
    • synchronized 보다 세밀한 제어가 가능하다. (타임아웃, 공정성 정책 등 설정 가능)
    • tryLock() 을 통해 락 획득 실패 시 대기하지 않고 다른 로직을 수행할 수 있다.

JVM 락은 "이 코드 블록에 동시에 들어오지 마라" 라는 수준의 제어일 뿐, 데이터를 안전하게 지켜준다는 보장하지 않는다.

2. Spring Data JPA(Application) 레벨

DB의 락 기능을 JPA가 추상화하여 제공하거나, 애플리케이션 로직으로 동시성을 제어하는 방식이다.

 

종류

  • Optimistic Lock (낙관적 락)
    • 개념 : "충돌이 잘 안 날 것이다"라고 가정하고, 락을 걸지 않고 버전(Version) 정보를 이용해 정합성을 맞춘다.
    • 구현 : 엔티티에 @Version 애노테이션이 붙은 필드를 추가한다.
    • 작동 방식 : 데이터를 읽을 때 버전도 같이 읽고, 업데이트 할 때 읽은 버전 == 현재 DB 버전 인지 확인한다. 다르다면 예외(ObjectOptimisticLockingFailureException)를 발생시키고 롤백한다.
    • 장점 : DB에 락을 걸지 않아 성능이 좋다.
    • 단점 : 충돌 발생 시 개발자가 재시도 로직(Retry)을 직접 구현해야 한다. 
  • Pessimistic Lock (비관적 락)
    • 개념 : "충돌이 무조건 발생할 것이다"라고 가정하고, 데이터를 읽을 때부터 DB에 실제 락(Shared, Exclusive)을 건다.
    • 구현 : Repository 메서드에 @Lock(LockModeType.PESSIMISTIC_WRITE) 등을 사용한다.
    • 작동 방식 : SQL 레벨에서 SELECT ... FOR UPDATE 쿼리가 나간다.
    • 장점 : 데이터 무결성이 확실하게 보장된다.
    • 단점 : 락을 점유하는 동안 다른 트랜잭션이 대기하므로 성능 저하 및 데드락 위험이 있다.

3. Database 레벨

실제 데이터가 저장되는 곳에서 발생하는 락이다.

종류

  • Shared Lock (공유 락)
    • 읽기 락
    • 내가 보고 있는 동안 남들도 볼 수는 있지만, 수정을 불가능하다.
    • 일반적인 SELECT는 락을 걸지 않지만, SELECT ... LOCK IN SHARE MODE 같은 쿼리로 명시할 수 있다.
  • Exclusive Lock (배타 락)
    • 쓰기 락
    • 내가 락을 걸면, 남들은 읽지도 쓰지도 못한다.
    • INSERT, UPDATE, DELETE 시 기본적으로 걸리며, SELECT ... FOR UPDATE로 읽기 시에도 강제로 걸 수 있다.
  • Record Lock & Gap Lock
    • Record Lock : DB의 인덱스 레코드 자체에 거는 락이다.
    • Gap Lock : 레코드와 레코드 사이의 범위에 거는 락이다. (Phantom Read 방지)
      • ex) ID가 1, 3인 데이터가 있을 때 ID 2인 데이터를 누군가 INSERT 하지 못하게 막는 역할
  • Named Lock 
    • 특정 데이터 Row가 아니라, 사용자가 지정한 "문자열"에 대해 락을 건다.
    • MySQL의 GET_LOCK(), RELEASE_LOCK() 함수를 사용한다.
    • 용도 : 분산 락을 구현하고 싶지만 Redis 같은 인프라를 도입하기 어려울 때 대안으로 사용된다.

결론

레벨 종류 적용 범위 특징
JVM synchronized, ReentrantLock 단일 서버 내부 빠르고 구현 쉬움, 분산 환경에서 무용지물
JPA Optimistic Lock (@Version) 애플리케이션 로직 DB락 안 검, 성능 좋음, 충돌 시 재시도 로직 필수
JPA Pessimistic Lock (@Lock) DB 락 매핑 FOR UPDATE 사용, 데이터 정합성 최고, 데드락 주의
DB S-Lock, X-Lock 데이터베이스 엔진  실제 물리적 차단, 트랜잭션 종료 시까지 유지됨 
DB Named Lock 메타데이터 분산 환경에서 Redis 없이 락 구현 시 유용

 

락은 단순히 "걸었느냐"의 문제가 아니라, 어디에서, 어떤 범위로 걸었느냐의 문제라고 생각한다.

 

도메인의 중요도와 서버 구조에 따라 적절한 레벨의 락을 선택하고 조합하는 것이 벡엔드 개발자의 핵심 설계 역량이라고 생각한다.