이번에 낙관적 락과 비관적 락에 대해서 어떠한 기능이 있고 무슨 차이를 나타내고 또 어떠한 방법으로도 동시성 문제를 해결할 수 있는지를 정리를 하며 블로그 글을 남기려고 합니다.
락이란?

락(Lock)은 동시성 문제를 해결하기 위해 사용되는 대표적인 동시성 제어 기법 중 하나로, 데이터베이스의 일관성과 무결성을 유지하기 위해 트랜잭션의 순차적 진행을 보장하는 장치입니다. 주로 두 명 이상의 사용자가 동시에 같은 데이터를 접근하는 상황에서 데이터 충돌이나 손실을 방지하기 위해 사용됩니다.
예를 들어, 트랜잭션만으로는 해결할 수 없는 갱신 손실 문제나 동시 요청으로 인해 발생하는 오류를 방지할 수 있습니다. 이를 통해 데이터의 일관성과 무결성을 유지할 수 있게 되는 것입니다.
락은 왜 필요할까?
락의 필요성을 설명할 때 흔히 사용되는 예시로 은행 계좌를 들 수 있습니다. 예를 들어, 사용자 A가 B와 C에게 각각 돈을 빌려주었고, B와 C가 동시에 사용자 A의 계좌로 돈을 송금한다고 가정해봅시다. 만약 사용자 A의 초기 잔액이 1,000원이었고 B와 C가 각각 10,000원을 송금한다면, 정상적으로 처리된다면 최종 잔액은 21,000원이 되어야 합니다. 하지만 동시성 문제로 인해 정확히 처리되지 않으면 잔액이 11,000원처럼 잘못된 값이 나타날 수 있습니다.
비슷한 상황이 뭐가 있을까?
이와 유사한 상황은 다양한 곳에서 발생할 수 있습니다. 예를 들어, 수강신청 시스템에서 정원이 1명 남아있을 때 여러 명이 동시에 신청을 시도하거나, 도서 대여 시스템에서 두 명이 거의 동시에 대여 신청을 하는 경우, 혹은 쇼핑몰에서 재고가 0 미만으로 떨어지지 않도록 관리해야 하는데 동시에 재고에 대한 결제 주문이 들어와 재고가 마이너스로 표시되는 경우 등입니다. 이러한 동시성 문제는 개발자들이 반드시 해결해야 할 중요한 과제 중 하나입니다. 위와 같은 동시성 문제를 방지하고 데이터의 일관성과 무결성을 지키기 위해 락을 사용한다.
결론적으로, 이러한 동시성 문제를 방지하고 데이터의 무결성과 일관성을 지키기 위해 락을 사용합니다. 락은 다양한 기능에 따라 여러 종류로 나뉘며, 여기에서는 테이블 락이 아닌 레코드 락을 기준으로 설명을 이어가겠습니다.
낙관적 락
낙관적 락(Optimistic Lock)은 트랜잭션들이 충돌하지 않을 것이라는 낙관적인 가정을 기반으로 한 동시성 제어 방법입니다. 일반적으로 JPA에서 제공하는 Version 컬럼을 사용하여 구현되며, 데이터를 읽어올 때의 Version 값과 수정 후 커밋 시점의 Version 값을 비교합니다. 만약 두 값이 다르다면, 이는 다른 트랜잭션이 데이터를 수정했음을 의미하며, 충돌이 발생하게 됩니다.
낙관적 락은 데이터베이스 자체의 락 기능을 활용하는 것이 아니라, 애플리케이션 레벨에서 동작하는 락입니다. 이 방법은 데이터를 수정하기 전에 자원에 락을 걸어 선점하지 않고, 커밋 시점에 동시성 문제가 발생했을 때 이를 처리하는 방식입니다. 즉, 자원을 미리 점유하지 않으므로 트랜잭션 간 충돌이 드물게 발생하는 환경에서 효율적입니다.
동작 방식

- 유저 A와 유저 B가 데이터를 읽어옴
- 유저 A와 유저 B가 동일한 테이블의 동일한 행(Row)을 SELECT 합니다.
- 이때 두 트랜잭션이 읽어온 행의 Version 값은 모두 1입니다.
- 유저 A가 데이터를 수정
- 유저 A가 데이터를 수정한 뒤 COMMIT을 실행합니다.
- 데이터베이스는 해당 행의 Version 값을 1에서 2로 증가시킵니다.
- 유저 B가 데이터를 수정 시도
- 유저 B는 여전히 자신이 읽어온 Version 값 1을 가지고 있습니다.
- 유저 B가 데이터를 수정하려고 할 때, 데이터베이스는 현재 저장된 Version 값(2)과 유저 B가 보유한 Version 값(1)을 비교합니다.
- 버전 불일치로 인해 예외 발생
- 비교 결과, 유저 B가 가진 Version 값(1)이 데이터베이스에 저장된 최신 Version 값(2)과 다르므로 충돌이 발생했다고 판단합니다.
- 이로 인해 유저 B의 트랜잭션은 수정이 거부되고, 예외가 발생합니다.
비관적 락
비관적 락(Pessimistic Lock)은 트랜잭션들이 충돌할 가능성을 전제로 하고, 데이터를 안전하게 보호하기 위해 먼저 락을 걸어 동시성 문제를 방지하는 기법입니다.
트랜잭션이 시작되면, 데이터를 수정하기 전에 해당 데이터를 조회하면서 락을 설정합니다. 이를 위해 데이터베이스에서 제공하는 락 기능을 사용하며, 일반적으로 SELECT FOR UPDATE 구문을 활용합니다. 이는 "이 데이터를 수정하려고 하는 중이니, 다른 트랜잭션은 건드리지 마세요!"라는 뜻입니다.
비관적 락은 Version 컬럼을 사용하지 않으며, 데이터를 수정하는 즉시 충돌 여부를 감지합니다. 이러한 방법은 데이터 충돌을 사전에 방지할 수 있어, 중요한 데이터를 다루거나 동시성 문제가 자주 발생하는 환경에서 유용합니다.
락을 사용하는 동안 다른 트랜잭션은 해당 자원에 접근하지 못하고 대기하게 됩니다. 이를 방지하기 위해 락 대기 시간이 무한정 길어지지 않도록 타임아웃을 설정하는 것이 일반적입니다.
결론적으로, 비관적 락은 데이터 조회 시점에 락을 걸어 동시성 문제를 미리 방지하자는 방법론입니다. 대표적으로 공유락(Shared Lock)과 베타락(Exclusive Lock)으로 나뉘며, 상황에 따라 적절히 활용됩니다.
| 특징 | 공유락 (Shared Lock) | 베타락 (Exclusive Lock) |
| 목적 | 데이터를 읽는 동안 데이터의 무결성 보장 | 데이터를 읽거나 수정하는 동안 트랜잭션 접근 차단 |
| 읽기 작업 | 허용 | 차단 |
| 쓰기 작업 | 차단 | 차단 |
| 주요 사용 시점 | 읽기 작업 중 데이터를 안정적으로 보호해야 할 때 | 읽기와 수정 모두 독점적으로 처리해야 할 때 |
| JPA 구현 | LockModeType.PESSIMISTIC_READ | LockModeType.PESSIMISTIC_WRITE |
| DBMS 구문 | SELECT ... FOR SHARE, SELECT ... LOCK IN SHARE MODE | SELECT ... FOR UPDATE |
공유락은 여러 사용자가 데이터를 동시에 읽는 것은 허용하지만, 데이터를 수정하려고 하면 차단됩니다. 예를 들어, 사용자 A가 공유락을 설정하고 데이터를 읽는 동안 사용자 B와 C도 데이터를 읽을 수 있습니다. 그러나 사용자 D가 데이터를 수정하려고 하면, 공유락이 해제될 때까지 대기해야 합니다.
베타락은 데이터를 완전히 독점하여 한 사용자가 데이터를 읽거나 수정하는 동안 다른 사용자는 데이터를 읽거나 수정하는 작업 자체가 불가능합니다. 예를 들어, 사용자 A가 베타락을 설정하고 데이터를 읽거나 수정 중이라면, 사용자 B와 C는 데이터에 접근할 수 없으며, 락이 해제될 때까지 대기해야 합니다.
간단히 말해, 공유락은 "읽기는 허용, 수정은 차단"하며, 베타락은 "읽기와 수정 모두 차단"하는 락입니다.
동작방식

- 유저 A가 데이터를 읽음
- 유저 A가 테이블의 특정 행(Row)을 읽으면 즉시 락이 설정됩니다.
- 이 락으로 인해 다른 트랜잭션은 해당 데이터를 수정하거나 조회할 수 없습니다.
- 유저 B는 대기 상태
- 동시에 유저 B가 동일한 데이터를 조회하려고 시도하지만, 이미 유저 A가 락을 설정했으므로 데이터 접근이 차단됩니다.
- 유저 B는 유저 A의 트랜잭션이 종료될 때까지 대기하게 됩니다.
- 유저 A가 트랜잭션 커밋
- 유저 A가 데이터를 수정한 뒤 트랜잭션을 커밋하면 락이 해제됩니다.
- 이제 다른 트랜잭션이 데이터에 접근할 수 있는 상태가 됩니다.
- 유저 B가 락을 획득
- 락이 해제되자마자 유저 B가 데이터를 조회하고 자신의 트랜잭션에 락을 설정합니다.
- 유저 B가 데이터를 수정하고 커밋
- 유저 B가 데이터를 수정한 뒤 트랜잭션을 커밋하면 락이 해제됩니다.
- 이후 다른 트랜잭션이 데이터를 조회하거나 수정할 수 있습니다.
스프링 부트 + JPA에서의 락 사용방법
낙관적 락은 충돌 가능성이 적다고 가정하고 동작하는 락 방식으로, JPA에서 제공하는 @Version 어노테이션을 활용해 엔티티의 버전을 관리합니다. @Version은 엔티티 필드에 적용하며, 가능타입은 Long, Integer, Short, java.sql.Timestamp입니다.
엔티티 값을 변경할 때마다 버전 값이 하나씩 증가하며, 수정 시점에 조회한 버전과 현재 데이터의 버전이 다르면 충돌로 간주하고 예외가 발생합니다. 각 엔티티 클래스에는 하나의 @Version 속성만 있어야 하며, 데이터 충돌 여부를 효율적으로 감지할 수 있습니다.
즉 엔티티의 값이 변경 될 때 마다 Version의 값이 증가하게 되며 조회 시점의 Version과 수정 시점의 Version이 다르면은 그 때 예외가 발생토록 합니다.
이 때 주의해야 할 것은 @Version 어노테이션은 엔티티의 id 필드에 적용하는 것이 아닌, 데이터 변경을 추적할 수 있는 별도의 필드에 사용하는 것입니다. 뭐 예를 들면 version이라는 필드를 따로 만들어 @Version 어노테이션을 적용할 수 있죠.
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Version
private Integer version; // 버전 필드
}
@Lock 어노테이션
JPA는 @Lock 어노테이션을 통해 다양한 락 모드를 제공합니다. 이를 사용하면 트랜잭션 내에서 동작 방식을 유연하게 설정할 수 있습니다. 주요 옵션은 아래와 같습니다.
| NONE | 기본 모드로, @Version을 사용하지 않고 버전 관리 없이 엔티티를 조회합니다. | 락이 필요하지 않은 기본 조회. |
| OPTIMISTIC | 엔티티 조회 시점에 버전을 확인하여 데이터 충돌 여부를 판단합니다. | 데이터 수정이 가능할 때 사용. |
| OPTIMISTIC_FORCE_INCREMENT | 트랜잭션을 커밋할 때 엔티티의 변경 여부와 상관없이 버전 값을 강제로 증가시킵니다. | 데이터 변경 이력을 남기고 싶을 때. |
| READ | OPTIMISTIC과 동일하며, 데이터를 읽기만 할 때 사용합니다. | 데이터 읽기 전용 트랜잭션에 사용. |
| WRITE | OPTIMISTIC_FORCE_INCREMENT와 동일하게 동작합니다. | 강제적인 버전 증가를 원하는 경우. |
LockModeType.OPTIMISTIC,
LockModeType.OPTIMISTIC_FORCE_INCREMENT,
LockModeType.READ,
LockModeType.WRITE
낙관적 락의 대표적인 옵션입니다.
LockModeType.PESSIMISTIC_READ,
LockModeType.PESSIMISTIC_WRITE
비관적 락의 대표적인 옵션입니다.
스프링부터 + JPA에서의 낙관적 락의 동작을 보자
낙관적 락 - @Version 어노테이션의 동작
-- 데이터 조회
select
item0_.id as id1_0_0_,
item0_.product_id as product_2_0_0_,
item0_.quantity as quantity3_0_0_,
item0_.version as version4_0_0_
from
item item0_
where
item0_.id = 1;
-- 데이터 업데이트
update
item
set
product_id = 1,
quantity = 9,
version = version + 1 -- 버전 값 증가
where
id = 1
and version = 0; -- 버전 비교
public interface ItemRepository extends JpaRepository<Item, Long> {
@Lock(LockModeType.OPTIMISTIC)
Optional<Item> findItemOptimisticLockById(@Param("id) Long id);
}
엔티티를 조회하면 JPA는 버전 컬럼도 함께 가져오며, 단순 조회 시에는 버전 값이 증가하지 않습니다. 하지만 엔티티를 수정하고 트랜잭션을 커밋하는 경우, UPDATE 쿼리가 실행되면서 버전 값이 version = version + 1로 증가합니다.
이때, WHERE 절에는 조회 시점의 버전 값이 조건으로 포함되며, 다른 트랜잭션에서 해당 데이터를 수정해 버전 값이 변경되었다면 조건을 만족하지 못해 업데이트가 실패하고 JPA가 예외를 발생시킵니다. 이를 통해 동시성 문제를 감지하고 데이터의 무결성을 보장할 수 있습니다.
비관적 락 - JPA가 제공해주는 비관적 락의 LockModeType
public interface ItemRepository extends JpaRepository<Item, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Item> findWithPessimisticLockById(@Param("id") Long id);
}
비관적 락은 데이터를 조회하는 즉시 락을 설정해 다른 트랜잭션이 접근하지 못하도록 막으며, 트랜잭션 충돌을 사전에 방지하는 동시성 제어 방식입니다. 주로 SELECT ... FOR UPDATE 구문을 사용하며, 데이터베이스 방언에 따라 세부적인 락 동작이 달라질 수 있습니다. 비관적 락은 데이터 수정 시 높은 안전성을 보장하지만, 다른 트랜잭션은 락이 해제될 때까지 대기해야 하는 특성이 있습니다.
비관적 락에서 가장 일반적으로 사용되는 옵션은 LockModeType.PESSIMISTIC_WRITE(베타락)입니다. 이 옵션은 데이터를 수정할 때 독점 락을 설정해 다른 트랜잭션의 충돌을 방지합니다.
반면, LockModeType.PESSIMISTIC_READ(공유락)는 데이터를 읽는 동안 안정적인 조회를 보장하며 수정 작업만 차단합니다. 다만, 수정이 빈번한 실무 환경에서는 PESSIMISTIC_READ가 잘 사용되지 않는 편입니다.
특히, LockModeType.PESSIMISTIC_FORCE_INCREMENT는 비관적 락 방식에 버전 관리 기능을 추가로 활용하는 특별한 옵션입니다. 하이버네이트는 데이터베이스가 지원하는 경우 FOR UPDATE NOWAIT와 같은 구문을 적용합니다.
비관적 락은 보통 @Version을 사용하지 않지만, PESSIMISTIC_FORCE_INCREMENT는 예외적으로 버전 관리 기능을 포함하여 데이터 간 충돌 방지와 작업 순서 제어를 강화합니다. 결론적으로, 비관적 락은 PESSIMISTIC_WRITE와 PESSIMISTIC_READ로 나뉘며, 각각 데이터 수정 충돌 방지와 안정적인 조회 보장을 위해 사용됩니다.
왜 낙관적 락이 아닌데 버전 관리를 하는가?
PESSIMISTIC_FORCE_INCREMENT는 비관적 락 방식이지만, 버전 정보를 증가시켜 데이터 간의 순서를 강제합니다.
버전 관리를 통해 특정 트랜잭션이 데이터를 수정하지 않아도 다른 트랜잭션이 이후에 작업을 수행할 때 버전 비교를 통해 명확한 충돌 감지가 가능합니다. 이는 비관적 락의 강력한 충돌 방지와 낙관적 락의 충돌 감지 기능을 결합한 형태입니다.
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Version
private Integer version; // 버전 필드
}
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findWithForceIncrementLock(@Param("id") Long id);
}
트랜잭션 A가 데이터를 읽으면서 PESSIMISTIC_FORCE_INCREMENT 락을 사용하면, 해당 데이터의 버전이 강제로 증가합니다. 트랜잭션 B는 트랜잭션 A의 락이 해제될 때까지 대기하며, 이후 버전 정보를 기준으로 작업 충돌 여부를 감지합니다.
락 모드비관적/낙관적읽기 가능쓰기 가능버전 관리 여부주요 특징
| 락 모드 | 비관적/낙관적 | 읽기 가능 | 쓰기 가능 | 버전 관리 여부 | 주요 특징 |
| PESSIMISTIC_WRITE (베타락) |
비관적 | 불가능 | 불가능 | X | 데이터 독점. 수정 시 다른 트랜잭션 접근 차단. |
| PESSIMISTIC_READ (공유락) |
비관적 | 가능 | 불가능 | X | 읽기는 허용하지만, 수정은 불가능. |
| PESSIMISTIC_FORCE _INCREMENT |
비관적 | 불가능 | 불가능 | O | 버전 정보를 강제로 증가하며 데이터 독점. |
| OPTIMISTIC_FORCE _INCREMENT |
낙관적 | 가능 | 가능 | O | 수정하지 않아도 버전 증가. 충돌 감지. |
PESSIMISTIC_WRITE (쓰기 잠금)
select
item0_.id as id1_0_,
item0_.product_id as product_2_0_,
item0_.quantity as quantity3_0_
from
item item0_
where
item0_.id = 1
for update;
public interface ItemRepository extends JpaRepository<Item, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT i FROM Item i WHERE i.id = :id")
Optional<Item> findWithPessimisticWrite(@Param("id") Long id);
}
PESSIMISTIC_READ (읽기 잠금)
select
item0_.id as id1_0_,
item0_.product_id as product_2_0_,
item0_.quantity as quantity3_0_
from
item item0_
where
item0_.id = 1
for share;
public interface ItemRepository extends JpaRepository<Item, Long> {
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("SELECT i FROM Item i WHERE i.id = :id")
Optional<Item> findWithPessimisticRead(@Param("id") Long id);
}
타임아웃 설정
타임아웃은 @QueryHint를 사용하여 설정합니다.
public interface ItemRepository extends JpaRepository<Item, Long> {
@Lock(LockModeType.PESSIMISTIC_READ)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "5000")}) // 타임아웃 5초
@Query("SELECT i FROM Item i WHERE i.id = :id")
Optional<Item> findWithPessimisticReadWithTimeout(@Param("id") Long id);
}
낙관적 락과 비관적 락은 각각의 장단점과 적합한 사용 시점이 있기 때문에, 상황에 따라 적절히 선택해야 합니다. 낙관적 락은 데이터 충돌이 자주 발생하지 않을 것으로 예상되는 시나리오에 적합합니다.
이 방식은 실제로 락을 잡지 않기 때문에 읽기 작업이 빈번하고 성능이 중요한 시스템에서 효과적입니다. 그러나 충돌이 발생할 경우 롤백과 복구 작업이 필요하므로, 트랜잭션 충돌이 빈번한 환경에서는 적합하지 않습니다.
반면, 비관적 락은 데이터의 무결성이 중요하고 충돌이 자주 발생할 것으로 예상되는 시나리오에 적합합니다. 비관적 락은 데이터를 안전하게 보호할 수 있지만, 데이터에 락을 설정하므로 데드락이 발생할 가능성이 있으며, 동시성 처리가 제한되어 성능 저하가 발생할 수 있습니다.
따라서 읽기 작업이 많은 데이터베이스 환경에서는 비효율적일 수 있습니다. 락은 비용이 비싸기 때문에 두 락의 특징과 코스트를 이해하고, 현재 마주친 문제의 특성을 분석해 적절히 처리하는 것이 중요합니다. 또한, 관련 예외 발생 시의 처리 방안도 미리 고민해야 하며, 낙관적 락과 비관적 락 외에 동시성 문제를 해결할 수 있는 다른 대안도 검토해 보는 것이 좋습니다.
마무리로 제가 게시글의 조회수 처리를 할 때 사용했던 로직을 보겠습니다.
@Modifying
@Query("UPDATE Post p SET p.views = p.views + 1 WHERE p.postCode = :postCode")
void incrementViews(@Param("postCode") Long postCode);
지금까지 정리하였던 락을 사용하지 않고, DB단에서 동시성을 처리하게 두었습니다. 사실 DB단이라고 하면 비관적 락의 베타적 락, 공유 락이랑 같은 개념으로, DB 내부 자체 매커니즘으로 동시성을 염두해두어서 자체적으로 행 락을 거는 겁니다.
제가 하고 싶은 말은 필드 단위로 보았을 때는, 이렇게 JPQL로 네이티브 SQL처리를 함으로써 행 락을 걸 수 있지만, 엔티티 전체의 단위로 볼 경우 혹은 좀 더 세세한 단위로 락을 걸어야 될 경우에는 어플리케이션 레벨에서의 락을 거는 것이라고 보시면 됩니다.
'spring' 카테고리의 다른 글
| 스프링 3대 기술 (0) | 2025.09.18 |
|---|---|
| [JPA] JPA 속 키 매핑 방법과 배치 전략 속 키 매핑 방법 (1) | 2024.12.22 |
| 스프링에서 쓰레드는 어떻게 사용되고 멀티쓰레드는 언제 사용할까? (1) | 2024.11.20 |
| JPA에서의 N+1 문제란? (0) | 2024.09.24 |
| AOP와 스프링 AOP란? (0) | 2024.09.23 |